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