svm/
install.rs

1use crate::{
2    SvmError, all_releases, data_dir, platform, releases::artifact_url, setup_data_dir,
3    setup_version, version_binary,
4};
5use semver::Version;
6use sha2::Digest;
7use std::{
8    fs,
9    io::Write,
10    path::{Path, PathBuf},
11    process::Command,
12    time::Duration,
13};
14use tempfile::NamedTempFile;
15
16#[cfg(target_family = "unix")]
17use std::{fs::Permissions, os::unix::fs::PermissionsExt};
18
19/// The timeout to use for requests to the source (10 minutes).
20const REQUEST_TIMEOUT: Duration = Duration::from_secs(600);
21
22/// Version beyond which solc binaries are not fully static, hence need to be patched for NixOS.
23const NIXOS_MIN_PATCH_VERSION: Version = Version::new(0, 7, 6);
24
25/// Version beyond which solc binaries are fully static again, hence no patching is needed for NixOS.
26/// See <https://github.com/ethereum/solidity/releases/tag/v0.8.29>
27const NIXOS_MAX_PATCH_VERSION: Version = Version::new(0, 8, 28);
28
29/// Blocking version of [`install`]
30#[cfg(feature = "blocking")]
31pub fn blocking_install(version: &Version) -> Result<PathBuf, SvmError> {
32    setup_data_dir()?;
33
34    let artifacts = crate::blocking_all_releases(platform::platform())?;
35    let artifact = artifacts
36        .get_artifact(version)
37        .ok_or_else(|| SvmError::UnknownVersion(version.clone()))?;
38    let download_url = artifact_url(platform::platform(), version, artifact.to_string().as_str())?;
39
40    let expected_checksum = artifacts
41        .get_checksum(version)
42        .unwrap_or_else(|| panic!("checksum not available: {:?}", version.to_string()));
43
44    let res = reqwest::blocking::Client::builder()
45        .timeout(REQUEST_TIMEOUT)
46        .build()
47        .expect("reqwest::Client::new()")
48        .get(download_url.clone())
49        .send()?;
50
51    if !res.status().is_success() {
52        return Err(SvmError::UnsuccessfulResponse(download_url, res.status()));
53    }
54
55    let binbytes = res.bytes()?;
56    ensure_checksum(&binbytes, version, &expected_checksum)?;
57
58    // lock file to indicate that installation of this solc version will be in progress.
59    let lock_path = lock_file_path(version);
60    // wait until lock file is released, possibly by another parallel thread trying to install the
61    // same version of solc.
62    let _lock = try_lock_file(lock_path)?;
63
64    do_install_and_retry(
65        version,
66        &binbytes,
67        artifact.to_string().as_str(),
68        &expected_checksum,
69    )
70}
71
72/// Installs the provided version of Solc in the machine.
73///
74/// Returns the path to the solc file.
75pub async fn install(version: &Version) -> Result<PathBuf, SvmError> {
76    setup_data_dir()?;
77
78    let artifacts = all_releases(platform::platform()).await?;
79    let artifact = artifacts
80        .get_artifact(version)
81        .ok_or_else(|| SvmError::UnknownVersion(version.clone()))?;
82    let download_url = artifact_url(platform::platform(), version, artifact.to_string().as_str())?;
83
84    let expected_checksum = artifacts
85        .get_checksum(version)
86        .unwrap_or_else(|| panic!("checksum not available: {:?}", version.to_string()));
87
88    let res = reqwest::Client::builder()
89        .timeout(REQUEST_TIMEOUT)
90        .build()
91        .expect("reqwest::Client::new()")
92        .get(download_url.clone())
93        .send()
94        .await?;
95
96    if !res.status().is_success() {
97        return Err(SvmError::UnsuccessfulResponse(download_url, res.status()));
98    }
99
100    let binbytes = res.bytes().await?;
101    ensure_checksum(&binbytes, version, &expected_checksum)?;
102
103    // lock file to indicate that installation of this solc version will be in progress.
104    let lock_path = lock_file_path(version);
105    // wait until lock file is released, possibly by another parallel thread trying to install the
106    // same version of solc.
107    let _lock = try_lock_file(lock_path)?;
108
109    do_install_and_retry(
110        version,
111        &binbytes,
112        artifact.to_string().as_str(),
113        &expected_checksum,
114    )
115}
116
117/// Same as [`do_install`] but retries "text file busy" errors.
118fn do_install_and_retry(
119    version: &Version,
120    binbytes: &[u8],
121    artifact: &str,
122    expected_checksum: &[u8],
123) -> Result<PathBuf, SvmError> {
124    let mut retries = 0;
125
126    loop {
127        return match do_install(version, binbytes, artifact) {
128            Ok(path) => Ok(path),
129            Err(err) => {
130                // installation failed
131                if retries > 2 {
132                    return Err(err);
133                }
134                retries += 1;
135                // check if this failed due to a text file busy, which indicates that a different process started using the target file
136                if err.to_string().to_lowercase().contains("text file busy") {
137                    // busy solc can be in use for a while (e.g. if compiling a large project), so we check if the file exists and has the correct checksum
138                    let solc_path = version_binary(&version.to_string());
139                    if solc_path.exists()
140                        && let Ok(content) = fs::read(&solc_path)
141                        && ensure_checksum(&content, version, expected_checksum).is_ok()
142                    {
143                        // checksum of the existing file matches the expected release checksum
144                        return Ok(solc_path);
145                    }
146
147                    // retry after some time
148                    std::thread::sleep(Duration::from_millis(250));
149                    continue;
150                }
151
152                Err(err)
153            }
154        };
155    }
156}
157
158fn do_install(version: &Version, binbytes: &[u8], _artifact: &str) -> Result<PathBuf, SvmError> {
159    setup_version(&version.to_string())?;
160    let installer = Installer { version, binbytes };
161
162    // Solc versions <= 0.7.1 are .zip files for Windows only
163    #[cfg(target_os = "windows")]
164    if _artifact.ends_with(".zip") {
165        return installer.install_zip();
166    }
167
168    installer.install()
169}
170
171/// Creates the file and locks it exclusively, this will block if the file is currently locked.
172fn try_lock_file(lock_path: PathBuf) -> Result<LockFile, SvmError> {
173    let _lock_file = fs::OpenOptions::new()
174        .create(true)
175        .truncate(true)
176        .read(true)
177        .write(true)
178        .open(&lock_path)?;
179    _lock_file.lock()?;
180    Ok(LockFile {
181        lock_path,
182        _lock_file,
183    })
184}
185
186/// Represents a lockfile that's removed once dropped
187struct LockFile {
188    _lock_file: fs::File,
189    lock_path: PathBuf,
190}
191
192impl Drop for LockFile {
193    fn drop(&mut self) {
194        let _ = fs::remove_file(&self.lock_path);
195    }
196}
197
198/// Returns the lockfile to use for a specific file
199fn lock_file_path(version: &Version) -> PathBuf {
200    data_dir().join(format!(".lock-solc-{version}"))
201}
202
203// Installer type that copies binary data to the appropriate solc binary file:
204// 1. create target file to copy binary data
205// 2. copy data
206struct Installer<'a> {
207    // version of solc
208    version: &'a Version,
209    // binary data of the solc executable
210    binbytes: &'a [u8],
211}
212
213impl Installer<'_> {
214    /// Installs the solc version at the version specific destination and returns the path to the installed solc file.
215    fn install(self) -> Result<PathBuf, SvmError> {
216        let named_temp_file = NamedTempFile::new_in(data_dir())?;
217        let (mut f, temp_path) = named_temp_file.into_parts();
218
219        #[cfg(target_family = "unix")]
220        f.set_permissions(Permissions::from_mode(0o755))?;
221        f.write_all(self.binbytes)?;
222
223        if platform::is_nixos()
224            && *self.version >= NIXOS_MIN_PATCH_VERSION
225            && *self.version <= NIXOS_MAX_PATCH_VERSION
226        {
227            patch_for_nixos(&temp_path)?;
228        }
229
230        let solc_path = version_binary(&self.version.to_string());
231
232        // Windows requires that the old file be moved out of the way first.
233        if cfg!(target_os = "windows") {
234            let temp_path = NamedTempFile::new_in(data_dir()).map(NamedTempFile::into_temp_path)?;
235            fs::rename(&solc_path, &temp_path).unwrap_or_default();
236        }
237
238        temp_path.persist(&solc_path)?;
239
240        Ok(solc_path)
241    }
242
243    /// Extracts the solc archive at the version specified destination and returns the path to the
244    /// installed solc binary.
245    #[cfg(target_os = "windows")]
246    fn install_zip(self) -> Result<PathBuf, SvmError> {
247        let solc_path = version_binary(&self.version.to_string());
248        let version_path = solc_path.parent().unwrap();
249
250        let mut content = std::io::Cursor::new(self.binbytes);
251        let mut archive = zip::ZipArchive::new(&mut content)?;
252        archive.extract(version_path)?;
253
254        std::fs::rename(version_path.join("solc.exe"), &solc_path)?;
255
256        Ok(solc_path)
257    }
258}
259
260/// Patch the given binary to use the dynamic linker provided by nixos.
261fn patch_for_nixos(bin: &Path) -> Result<(), SvmError> {
262    let output = Command::new("nix-shell")
263        .arg("-p")
264        .arg("patchelf")
265        .arg("--run")
266        .arg(format!(
267            "patchelf --set-interpreter \"$(cat $NIX_CC/nix-support/dynamic-linker)\" {}",
268            bin.display()
269        ))
270        .output()
271        .map_err(|e| SvmError::CouldNotPatchForNixOs(String::new(), e.to_string()))?;
272
273    match output.status.success() {
274        true => Ok(()),
275        false => Err(SvmError::CouldNotPatchForNixOs(
276            String::from_utf8_lossy(&output.stdout).into_owned(),
277            String::from_utf8_lossy(&output.stderr).into_owned(),
278        )),
279    }
280}
281
282fn ensure_checksum(
283    binbytes: &[u8],
284    version: &Version,
285    expected_checksum: &[u8],
286) -> Result<(), SvmError> {
287    let mut hasher = sha2::Sha256::new();
288    hasher.update(binbytes);
289    let checksum = &hasher.finalize()[..];
290    // checksum does not match
291    if checksum != expected_checksum {
292        return Err(SvmError::ChecksumMismatch {
293            version: version.to_string(),
294            expected: hex::encode(expected_checksum),
295            actual: hex::encode(checksum),
296        });
297    }
298    Ok(())
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use rand::seq::IndexedRandom;
305
306    #[allow(unused)]
307    const LATEST: Version = Version::new(0, 8, 30);
308
309    #[tokio::test]
310    #[serial_test::serial]
311    async fn test_install() {
312        let versions = all_releases(platform())
313            .await
314            .unwrap()
315            .releases
316            .into_keys()
317            .collect::<Vec<Version>>();
318        let rand_version = versions.choose(&mut rand::rng()).unwrap();
319        assert!(install(rand_version).await.is_ok());
320    }
321
322    #[tokio::test]
323    #[serial_test::serial]
324    async fn can_install_while_solc_is_running() {
325        const WHICH: &str = if cfg!(target_os = "windows") {
326            "where"
327        } else {
328            "which"
329        };
330        // Long-running command: `sleep infinity` on Unix, `timeout /t 3600` on Windows
331        const CMD: &str = if cfg!(target_os = "windows") {
332            "timeout"
333        } else {
334            "sleep"
335        };
336
337        let version: Version = "0.8.10".parse().unwrap();
338        let solc_path = version_binary(version.to_string().as_str());
339
340        fs::create_dir_all(solc_path.parent().unwrap()).unwrap();
341
342        // Overwrite solc with a long-running command.
343        let stdout = Command::new(WHICH).arg(CMD).output().unwrap().stdout;
344        let cmd_path = String::from_utf8(stdout).unwrap();
345        // On Windows, `where` can return multiple paths - take the first one
346        let cmd_path = cmd_path.lines().next().unwrap_or(&cmd_path);
347        fs::copy(cmd_path.trim_end(), &solc_path).unwrap();
348
349        let mut child = if cfg!(target_os = "windows") {
350            Command::new(&solc_path)
351                .args(["/t", "3600"])
352                .spawn()
353                .unwrap()
354        } else {
355            Command::new(&solc_path).arg("infinity").spawn().unwrap()
356        };
357
358        // Install should not fail with "text file busy".
359        install(&version).await.unwrap();
360
361        child.kill().unwrap();
362        let _: std::process::ExitStatus = child.wait().unwrap();
363    }
364
365    #[cfg(feature = "blocking")]
366    #[serial_test::serial]
367    #[test]
368    fn blocking_test_install() {
369        let versions = crate::releases::blocking_all_releases(platform::platform())
370            .unwrap()
371            .into_versions();
372        let rand_version = versions.choose(&mut rand::rng()).unwrap();
373        assert!(blocking_install(rand_version).is_ok());
374    }
375
376    #[tokio::test]
377    #[serial_test::serial]
378    async fn test_version() {
379        let version = "0.8.10".parse().unwrap();
380        install(&version).await.unwrap();
381        let solc_path = version_binary(version.to_string().as_str());
382        let output = Command::new(solc_path).arg("--version").output().unwrap();
383        assert!(
384            String::from_utf8_lossy(&output.stdout)
385                .as_ref()
386                .contains("0.8.10")
387        );
388    }
389
390    #[cfg(feature = "blocking")]
391    #[serial_test::serial]
392    #[test]
393    fn blocking_test_latest() {
394        blocking_install(&LATEST).unwrap();
395        let solc_path = version_binary(LATEST.to_string().as_str());
396        let output = Command::new(solc_path).arg("--version").output().unwrap();
397
398        assert!(
399            String::from_utf8_lossy(&output.stdout)
400                .as_ref()
401                .contains(&LATEST.to_string())
402        );
403    }
404
405    #[cfg(feature = "blocking")]
406    #[serial_test::serial]
407    #[test]
408    fn blocking_test_version() {
409        let version = "0.8.10".parse().unwrap();
410        blocking_install(&version).unwrap();
411        let solc_path = version_binary(version.to_string().as_str());
412        let output = Command::new(solc_path).arg("--version").output().unwrap();
413
414        assert!(
415            String::from_utf8_lossy(&output.stdout)
416                .as_ref()
417                .contains("0.8.10")
418        );
419    }
420
421    #[cfg(feature = "blocking")]
422    #[test]
423    fn can_install_parallel() {
424        let version: Version = "0.8.10".parse().unwrap();
425        let cloned_version = version.clone();
426        let t = std::thread::spawn(move || blocking_install(&cloned_version));
427        blocking_install(&version).unwrap();
428        t.join().unwrap().unwrap();
429    }
430
431    #[tokio::test(flavor = "multi_thread")]
432    async fn can_install_parallel_async() {
433        let version: Version = "0.8.10".parse().unwrap();
434        let cloned_version = version.clone();
435        let t = tokio::task::spawn(async move { install(&cloned_version).await });
436        install(&version).await.unwrap();
437        t.await.unwrap().unwrap();
438    }
439
440    // ensures we can download the latest universal solc for apple silicon
441    #[tokio::test(flavor = "multi_thread")]
442    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
443    async fn can_install_latest_native_apple_silicon() {
444        let solc = install(&LATEST).await.unwrap();
445        let output = Command::new(solc).arg("--version").output().unwrap();
446        let version_output = String::from_utf8_lossy(&output.stdout);
447        assert!(
448            version_output.contains(&LATEST.to_string()),
449            "{version_output}"
450        );
451    }
452
453    // ensures we can download the latest native solc for linux aarch64
454    #[tokio::test(flavor = "multi_thread")]
455    #[cfg(all(target_os = "linux", target_arch = "aarch64"))]
456    async fn can_download_latest_linux_aarch64() {
457        let artifacts = all_releases(Platform::LinuxAarch64).await.unwrap();
458
459        let artifact = artifacts.releases.get(&LATEST).unwrap();
460        let download_url = artifact_url(
461            Platform::LinuxAarch64,
462            &LATEST,
463            artifact.to_string().as_str(),
464        )
465        .unwrap();
466
467        let checksum = artifacts.get_checksum(&LATEST).unwrap();
468
469        let resp = reqwest::get(download_url).await.unwrap();
470        assert!(resp.status().is_success());
471        let binbytes = resp.bytes().await.unwrap();
472        ensure_checksum(&binbytes, &LATEST, checksum).unwrap();
473    }
474
475    #[tokio::test]
476    #[cfg(target_os = "windows")]
477    async fn can_install_windows_zip_release() {
478        let version = "0.7.1".parse().unwrap();
479        install(&version).await.unwrap();
480        let solc_path = version_binary(version.to_string().as_str());
481        let output = Command::new(&solc_path).arg("--version").output().unwrap();
482
483        assert!(
484            String::from_utf8_lossy(&output.stdout)
485                .as_ref()
486                .contains("0.7.1")
487        );
488    }
489
490    #[cfg(feature = "blocking")]
491    #[serial_test::serial]
492    #[test]
493    #[ignore]
494    fn blocking_test_0_8_31_pre() {
495        let version = "0.8.31-pre.1".parse().unwrap();
496        blocking_install(&version).unwrap();
497        let solc_path = version_binary(version.to_string().as_str());
498        let output = Command::new(solc_path).arg("--version").output().unwrap();
499
500        assert!(
501            String::from_utf8_lossy(&output.stdout)
502                .as_ref()
503                .contains(&version.to_string())
504        );
505    }
506}