use crate::{
SvmError, all_releases, data_dir, platform, releases::artifact_url, setup_data_dir,
setup_version, version_binary, version_path,
};
use semver::Version;
use sha2::Digest;
use std::{
fs,
io::{ErrorKind, Read, Write},
path::{Path, PathBuf},
process::Command,
time::Duration,
};
use tempfile::NamedTempFile;
#[cfg(target_family = "unix")]
use std::{fs::Permissions, os::unix::fs::PermissionsExt};
const REQUEST_TIMEOUT: Duration = Duration::from_secs(600);
const NIXOS_MIN_PATCH_VERSION: Version = Version::new(0, 7, 6);
const NIXOS_MAX_PATCH_VERSION: Version = Version::new(0, 8, 28);
#[cfg(feature = "blocking")]
pub fn blocking_install(version: &Version) -> Result<PathBuf, SvmError> {
setup_data_dir()?;
let artifacts = crate::blocking_all_releases(platform::platform())?;
let artifact = artifacts
.get_artifact(version)
.ok_or_else(|| SvmError::UnknownVersion(version.clone()))?;
let download_url = artifact_url(platform::platform(), version, artifact.to_string().as_str())?;
let expected_checksum = artifacts
.get_checksum(version)
.unwrap_or_else(|| panic!("checksum not available: {:?}", version.to_string()));
if let Some(solc_path) = find_reusable_installation(version, &expected_checksum, false)? {
return Ok(solc_path);
}
let res = reqwest::blocking::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.expect("reqwest::Client::new()")
.get(download_url.clone())
.send()?;
if !res.status().is_success() {
return Err(SvmError::UnsuccessfulResponse(download_url, res.status()));
}
let binbytes = res.bytes()?;
ensure_checksum(&binbytes, version, &expected_checksum)?;
setup_version(&version.to_string())?;
let lock_path = lock_file_path(version);
let _lock = try_lock_file(&lock_path)?;
do_install_and_retry(
version,
&binbytes,
artifact.to_string().as_str(),
&expected_checksum,
)
}
pub async fn install(version: &Version) -> Result<PathBuf, SvmError> {
setup_data_dir()?;
let artifacts = all_releases(platform::platform()).await?;
let artifact = artifacts
.get_artifact(version)
.ok_or_else(|| SvmError::UnknownVersion(version.clone()))?;
let download_url = artifact_url(platform::platform(), version, artifact.to_string().as_str())?;
let expected_checksum = artifacts
.get_checksum(version)
.unwrap_or_else(|| panic!("checksum not available: {:?}", version.to_string()));
if let Some(solc_path) = find_reusable_installation(version, &expected_checksum, false)? {
return Ok(solc_path);
}
let res = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()
.expect("reqwest::Client::new()")
.get(download_url.clone())
.send()
.await?;
if !res.status().is_success() {
return Err(SvmError::UnsuccessfulResponse(download_url, res.status()));
}
let binbytes = res.bytes().await?;
ensure_checksum(&binbytes, version, &expected_checksum)?;
setup_version(&version.to_string())?;
let lock_path = lock_file_path(version);
let _lock = try_lock_file(&lock_path)?;
do_install_and_retry(
version,
&binbytes,
artifact.to_string().as_str(),
&expected_checksum,
)
}
fn find_reusable_installation(
version: &Version,
expected_checksum: &[u8],
_repair_permissions: bool,
) -> Result<Option<PathBuf>, SvmError> {
find_reusable_installation_for_platform(
version,
expected_checksum,
_repair_permissions,
platform::is_nixos(),
)
}
fn find_reusable_installation_for_platform(
version: &Version,
expected_checksum: &[u8],
_repair_permissions: bool,
is_nixos: bool,
) -> Result<Option<PathBuf>, SvmError> {
if requires_nixos_patch(version, is_nixos) {
return Ok(None);
}
let solc_path = version_binary(&version.to_string());
let Ok(mut file) = fs::File::open(&solc_path) else {
return Ok(None);
};
let mut content = Vec::new();
if file.read_to_end(&mut content).is_err()
|| ensure_checksum(&content, version, expected_checksum).is_err()
{
return Ok(None);
}
#[cfg(target_family = "unix")]
if file.metadata()?.permissions().mode() & 0o111 != 0o111 {
if !_repair_permissions {
return Ok(None);
}
file.set_permissions(Permissions::from_mode(0o755))?;
}
Ok(Some(solc_path))
}
fn requires_nixos_patch(version: &Version, is_nixos: bool) -> bool {
is_nixos && *version >= NIXOS_MIN_PATCH_VERSION && *version <= NIXOS_MAX_PATCH_VERSION
}
fn do_install_and_retry(
version: &Version,
binbytes: &[u8],
artifact: &str,
expected_checksum: &[u8],
) -> Result<PathBuf, SvmError> {
let mut retries = 0;
loop {
if let Some(solc_path) = find_reusable_installation(version, expected_checksum, true)? {
return Ok(solc_path);
}
return match do_install(version, binbytes, artifact) {
Ok(path) => Ok(path),
Err(err) => {
if retries > 2 {
return Err(err);
}
retries += 1;
if err.to_string().to_lowercase().contains("text file busy") {
std::thread::sleep(Duration::from_millis(250));
continue;
}
Err(err)
}
};
}
}
fn do_install(version: &Version, binbytes: &[u8], _artifact: &str) -> Result<PathBuf, SvmError> {
setup_version(&version.to_string())?;
let installer = Installer { version, binbytes };
#[cfg(target_os = "windows")]
if _artifact.ends_with(".zip") {
return installer.install_zip();
}
installer.install()
}
fn try_lock_file(lock_path: &Path) -> Result<fs::File, SvmError> {
loop {
let lock_file = fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(lock_path)?;
lock_file.lock()?;
if lock_is_current(&lock_file, lock_path)? {
return Ok(lock_file);
}
}
}
#[cfg(target_family = "unix")]
fn lock_is_current(lock_file: &fs::File, lock_path: &Path) -> Result<bool, SvmError> {
use std::os::unix::fs::MetadataExt;
let held = lock_file.metadata()?;
match fs::metadata(lock_path) {
Ok(current) => Ok(current.dev() == held.dev() && current.ino() == held.ino()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(err) => Err(err.into()),
}
}
#[cfg(not(target_family = "unix"))]
fn lock_is_current(_lock_file: &fs::File, _lock_path: &Path) -> Result<bool, SvmError> {
Ok(true)
}
fn lock_file_path(version: &Version) -> PathBuf {
version_path(&version.to_string()).join(".lock")
}
struct Installer<'a> {
version: &'a Version,
binbytes: &'a [u8],
}
impl Installer<'_> {
fn install(self) -> Result<PathBuf, SvmError> {
let version = self.version.to_string();
let version_dir = version_path(&version);
let solc_path = version_binary(&version);
let temp_path = self.prepare()?;
if cfg!(target_os = "windows") {
let temp_path =
NamedTempFile::new_in(&version_dir).map(NamedTempFile::into_temp_path)?;
fs::rename(&solc_path, &temp_path).unwrap_or_default();
}
temp_path.persist(&solc_path)?;
Ok(solc_path)
}
fn prepare(self) -> Result<tempfile::TempPath, SvmError> {
let version_dir = version_path(&self.version.to_string());
let named_temp_file = NamedTempFile::new_in(&version_dir)?;
let (mut f, temp_path) = named_temp_file.into_parts();
#[cfg(target_family = "unix")]
f.set_permissions(Permissions::from_mode(0o755))?;
f.write_all(self.binbytes)?;
f.sync_data()?;
drop(f);
if requires_nixos_patch(self.version, platform::is_nixos()) {
patch_for_nixos(self.version, &temp_path)?;
}
Ok(temp_path)
}
#[cfg(target_os = "windows")]
fn install_zip(self) -> Result<PathBuf, SvmError> {
let solc_path = version_binary(&self.version.to_string());
let version_path = solc_path.parent().unwrap();
let mut content = std::io::Cursor::new(self.binbytes);
let mut archive = zip::ZipArchive::new(&mut content)?;
archive.extract(version_path)?;
std::fs::rename(version_path.join("solc.exe"), &solc_path)?;
Ok(solc_path)
}
}
fn patch_for_nixos(version: &Version, bin: &Path) -> Result<(), SvmError> {
let dynamic_linker = nixos_dynamic_linker()?;
add_gc_root_for_store_path(version, &dynamic_linker)?;
let output = Command::new("nix-shell")
.arg("-p")
.arg("patchelf")
.arg("--run")
.arg(format!(
"patchelf --set-interpreter \"{}\" {}",
dynamic_linker,
bin.display()
))
.output()
.map_err(|e| SvmError::CouldNotPatchForNixOs(String::new(), e.to_string()))?;
match output.status.success() {
true => Ok(()),
false => Err(SvmError::CouldNotPatchForNixOs(
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
)),
}
}
fn nixos_dynamic_linker() -> Result<String, SvmError> {
let output = Command::new("nix-shell")
.arg("-p")
.arg("patchelf")
.arg("--run")
.arg("cat $NIX_CC/nix-support/dynamic-linker")
.output()
.map_err(|e| SvmError::CouldNotPatchForNixOs(String::new(), e.to_string()))?;
if !output.status.success() {
return Err(SvmError::CouldNotPatchForNixOs(
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
));
}
let dynamic_linker = String::from_utf8_lossy(&output.stdout).trim().to_string();
if dynamic_linker.is_empty() {
return Err(SvmError::CouldNotPatchForNixOs(
String::new(),
"empty dynamic linker path from nix-shell".to_string(),
));
}
Ok(dynamic_linker)
}
fn add_gc_root_for_store_path(version: &Version, store_path: &str) -> Result<(), SvmError> {
let gcroots_dir = data_dir().join(".gcroots");
fs::create_dir_all(&gcroots_dir)?;
let root_path = gcroots_dir.join(format!("solc-{version}-dynamic-linker"));
match fs::remove_file(&root_path) {
Ok(()) => {}
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) => return Err(err.into()),
}
let output = Command::new("nix-store")
.arg("--add-root")
.arg(&root_path)
.arg("--realise")
.arg(store_path)
.output()
.map_err(|e| SvmError::CouldNotAddNixGcRoot(String::new(), e.to_string()))?;
if output.status.success() {
Ok(())
} else {
Err(SvmError::CouldNotAddNixGcRoot(
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
))
}
}
fn ensure_checksum(
binbytes: &[u8],
version: &Version,
expected_checksum: &[u8],
) -> Result<(), SvmError> {
let mut hasher = sha2::Sha256::new();
hasher.update(binbytes);
let checksum = &hasher.finalize()[..];
if checksum != expected_checksum {
return Err(SvmError::ChecksumMismatch {
version: version.to_string(),
expected: hex::encode(expected_checksum),
actual: hex::encode(checksum),
});
}
Ok(())
}
#[cfg(test)]
#[cfg(target_family = "unix")]
mod regression;
#[cfg(test)]
mod tests {
use super::*;
use rand::seq::IndexedRandom;
#[allow(unused)]
const LATEST: Version = Version::new(0, 8, 36);
#[tokio::test]
async fn test_install() {
let versions = all_releases(platform())
.await
.unwrap()
.releases
.into_keys()
.collect::<Vec<Version>>();
let rand_version = versions.choose(&mut rand::rng()).unwrap();
assert!(install(rand_version).await.is_ok());
}
#[tokio::test]
async fn can_install_while_solc_is_running() {
const WHICH: &str = if cfg!(target_os = "windows") {
"where"
} else {
"which"
};
const CMD: &str = if cfg!(target_os = "windows") {
"timeout"
} else {
"sleep"
};
let version: Version = "0.8.10".parse().unwrap();
let solc_path = version_binary(version.to_string().as_str());
fs::create_dir_all(solc_path.parent().unwrap()).unwrap();
let stdout = Command::new(WHICH).arg(CMD).output().unwrap().stdout;
let cmd_path = String::from_utf8(stdout).unwrap();
let cmd_path = cmd_path.lines().next().unwrap_or(&cmd_path);
fs::copy(cmd_path.trim_end(), &solc_path).unwrap();
let mut child = if cfg!(target_os = "windows") {
Command::new(&solc_path)
.args(["/t", "3600"])
.spawn()
.unwrap()
} else {
Command::new(&solc_path).arg("infinity").spawn().unwrap()
};
install(&version).await.unwrap();
child.kill().unwrap();
let _: std::process::ExitStatus = child.wait().unwrap();
}
#[cfg(target_family = "unix")]
#[test]
fn install_reuses_existing_binary_while_running() {
let version: Version = "0.8.19".parse().unwrap();
let solc_path = version_binary(version.to_string().as_str());
fs::create_dir_all(solc_path.parent().unwrap()).unwrap();
let stdout = Command::new("which").arg("sleep").output().unwrap().stdout;
let sleep_path = String::from_utf8(stdout).unwrap();
fs::copy(sleep_path.trim_end(), &solc_path).unwrap();
let binbytes = fs::read(&solc_path).unwrap();
let expected_checksum = &sha2::Sha256::digest(&binbytes)[..];
let mut child = Command::new(&solc_path).arg("30").spawn().unwrap();
let _lock = try_lock_file(&lock_file_path(&version)).unwrap();
let installed = do_install_and_retry(
&version,
b"different binary contents",
"",
expected_checksum,
)
.unwrap();
assert_eq!(installed, solc_path);
assert!(
fs::read(&solc_path).unwrap() == binbytes,
"the running binary was replaced"
);
child.kill().unwrap();
let _: std::process::ExitStatus = child.wait().unwrap();
}
#[test]
fn install_replaces_corrupt_binary() {
let version: Version = "0.8.21".parse().unwrap();
let solc_path = version_binary(version.to_string().as_str());
fs::create_dir_all(solc_path.parent().unwrap()).unwrap();
fs::write(&solc_path, b"corrupt binary contents").unwrap();
let binbytes = b"expected binary contents";
let expected_checksum = &sha2::Sha256::digest(binbytes)[..];
let _lock = try_lock_file(&lock_file_path(&version)).unwrap();
let installed = do_install_and_retry(&version, binbytes, "", expected_checksum).unwrap();
assert_eq!(installed, solc_path);
assert_eq!(fs::read(&solc_path).unwrap(), binbytes);
}
#[test]
fn lock_file_is_not_removed() {
let version: Version = "0.8.13".parse().unwrap();
setup_data_dir().unwrap();
setup_version(version.to_string().as_str()).unwrap();
let lock_path = lock_file_path(&version);
drop(try_lock_file(&lock_path).unwrap());
assert!(lock_path.exists());
drop(try_lock_file(&lock_path).unwrap());
}
#[cfg(feature = "blocking")]
#[test]
fn blocking_test_install() {
let versions = crate::releases::blocking_all_releases(platform::platform())
.unwrap()
.into_versions();
let rand_version = versions.choose(&mut rand::rng()).unwrap();
assert!(blocking_install(rand_version).is_ok());
}
#[tokio::test]
async fn test_version() {
let version = "0.8.10".parse().unwrap();
install(&version).await.unwrap();
let solc_path = version_binary(version.to_string().as_str());
let output = Command::new(solc_path).arg("--version").output().unwrap();
assert!(
String::from_utf8_lossy(&output.stdout)
.as_ref()
.contains("0.8.10")
);
}
#[cfg(feature = "blocking")]
#[test]
fn blocking_test_latest() {
blocking_install(&LATEST).unwrap();
let solc_path = version_binary(LATEST.to_string().as_str());
let output = Command::new(solc_path).arg("--version").output().unwrap();
assert!(
String::from_utf8_lossy(&output.stdout)
.as_ref()
.contains(&LATEST.to_string())
);
}
#[cfg(feature = "blocking")]
#[test]
fn blocking_test_version() {
let version = "0.8.10".parse().unwrap();
blocking_install(&version).unwrap();
let solc_path = version_binary(version.to_string().as_str());
let output = Command::new(solc_path).arg("--version").output().unwrap();
assert!(
String::from_utf8_lossy(&output.stdout)
.as_ref()
.contains("0.8.10")
);
}
#[cfg(feature = "blocking")]
#[test]
fn can_install_parallel() {
let version: Version = "0.8.10".parse().unwrap();
let cloned_version = version.clone();
let t = std::thread::spawn(move || blocking_install(&cloned_version));
blocking_install(&version).unwrap();
t.join().unwrap().unwrap();
}
#[tokio::test(flavor = "multi_thread")]
async fn can_install_parallel_async() {
let version: Version = "0.8.10".parse().unwrap();
let cloned_version = version.clone();
let t = tokio::task::spawn(async move { install(&cloned_version).await });
install(&version).await.unwrap();
t.await.unwrap().unwrap();
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
async fn can_install_latest_native_apple_silicon() {
let solc = install(&LATEST).await.unwrap();
let output = Command::new(solc).arg("--version").output().unwrap();
let version_output = String::from_utf8_lossy(&output.stdout);
assert!(
version_output.contains(&LATEST.to_string()),
"{version_output}"
);
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
async fn can_download_linux_aarch64_latest() {
let artifacts = all_releases(platform::Platform::LinuxAarch64)
.await
.unwrap();
let artifact = artifacts.releases.get(&LATEST).unwrap();
let download_url = artifact_url(
platform::Platform::LinuxAarch64,
&LATEST,
artifact.to_string().as_str(),
)
.unwrap();
let checksum = artifacts.get_checksum(&LATEST).unwrap();
let resp = reqwest::get(download_url).await.unwrap();
assert!(resp.status().is_success());
let binbytes = resp.bytes().await.unwrap();
ensure_checksum(&binbytes, &LATEST, &checksum).unwrap();
}
#[tokio::test(flavor = "multi_thread")]
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
async fn can_download_linux_aarch64_thirdparty() {
let version: Version = "0.8.30".parse().unwrap();
let artifacts = all_releases(platform::Platform::LinuxAarch64)
.await
.unwrap();
let artifact = artifacts.releases.get(&version).unwrap();
let download_url = artifact_url(
platform::Platform::LinuxAarch64,
&version,
artifact.to_string().as_str(),
)
.unwrap();
let checksum = artifacts.get_checksum(&version).unwrap();
let resp = reqwest::get(download_url).await.unwrap();
assert!(resp.status().is_success());
let binbytes = resp.bytes().await.unwrap();
ensure_checksum(&binbytes, &version, &checksum).unwrap();
}
#[tokio::test]
#[cfg(target_os = "windows")]
async fn can_install_windows_zip_release() {
let version = "0.7.1".parse().unwrap();
install(&version).await.unwrap();
let solc_path = version_binary(version.to_string().as_str());
let output = Command::new(&solc_path).arg("--version").output().unwrap();
assert!(
String::from_utf8_lossy(&output.stdout)
.as_ref()
.contains("0.7.1")
);
}
#[cfg(feature = "blocking")]
#[test]
#[ignore]
fn blocking_test_0_8_31_pre() {
let version = "0.8.31-pre.1".parse().unwrap();
blocking_install(&version).unwrap();
let solc_path = version_binary(version.to_string().as_str());
let output = Command::new(solc_path).arg("--version").output().unwrap();
assert!(
String::from_utf8_lossy(&output.stdout)
.as_ref()
.contains(&version.to_string())
);
}
}