rust_binary_install/
lib.rs

1//! Utilities for finding and installing binaries that we depend on.
2
3use anyhow::{anyhow, Context};
4use fs2::FileExt;
5use siphasher::sip::SipHasher13;
6use std::collections::HashSet;
7use std::env;
8use std::ffi;
9use std::fs;
10use std::fs::File;
11use std::hash::{Hash, Hasher};
12use std::io;
13use std::path::{Path, PathBuf};
14
15/// Global cache for wasm-pack, currently containing binaries downloaded from
16/// urls like wasm-bindgen and such.
17#[derive(Debug)]
18pub struct Cache {
19    pub destination: PathBuf,
20}
21
22/// Representation of a downloaded tarball/zip
23#[derive(Debug, Clone)]
24pub struct Download {
25    root: PathBuf,
26}
27
28impl Cache {
29    /// Returns the global cache directory, as inferred from env vars and such.
30    ///
31    /// This function may return an error if a cache directory cannot be
32    /// determined.
33    pub fn new(name: &str) -> Result<Cache, anyhow::Error> {
34        let cache_name = format!(".{}", name);
35        let destination = dirs::cache_dir()
36            .map(|p| p.join(&cache_name))
37            .or_else(|| {
38                let home = dirs::home_dir()?;
39                Some(home.join(&cache_name))
40            })
41            .ok_or_else(|| anyhow!("couldn't find your home directory, is $HOME not set?"))?;
42        if !destination.exists() {
43            fs::create_dir_all(&destination)?;
44        }
45        Ok(Cache::at(&destination))
46    }
47
48    /// Creates a new cache specifically at a particular directory, useful in
49    /// testing and such.
50    pub fn at(path: &Path) -> Cache {
51        Cache {
52            destination: path.to_path_buf(),
53        }
54    }
55
56    /// Joins a path to the destination of this cache, returning the result
57    pub fn join(&self, path: &Path) -> PathBuf {
58        self.destination.join(path)
59    }
60
61    /// Downloads a tarball or zip file from the specified url, extracting it
62    /// to a directory with the version number and returning the directory that
63    /// the contents were extracted into.
64    ///
65    /// Note that this function requries that the contents of `url` never change
66    /// as the contents of the url are globally cached on the system and never
67    /// invalidated.
68    ///
69    /// The `name` is a human-readable name used to go into the folder name of
70    /// the destination, and `binaries` is a list of binaries expected to be at
71    /// the url. If the URL's extraction doesn't contain all the binaries this
72    /// function will return an error.
73    pub fn download_version(
74        &self,
75        install_permitted: bool,
76        name: &str,
77        binaries: &[&str],
78        url: &str,
79        version: &str,
80    ) -> Result<Option<Download>, anyhow::Error> {
81        self._download(install_permitted, name, binaries, url, Some(version))
82    }
83
84    /// Downloads a tarball or zip file from the specified url, extracting it
85    /// locally and returning the directory that the contents were extracted
86    /// into.
87    ///
88    /// Note that this function requries that the contents of `url` never change
89    /// as the contents of the url are globally cached on the system and never
90    /// invalidated.
91    ///
92    /// The `name` is a human-readable name used to go into the folder name of
93    /// the destination, and `binaries` is a list of binaries expected to be at
94    /// the url. If the URL's extraction doesn't contain all the binaries this
95    /// function will return an error.
96    pub fn download(
97        &self,
98        install_permitted: bool,
99        name: &str,
100        binaries: &[&str],
101        url: &str,
102    ) -> Result<Option<Download>, anyhow::Error> {
103        self._download(install_permitted, name, binaries, url, None)
104    }
105
106    fn _download(
107        &self,
108        install_permitted: bool,
109        name: &str,
110        binaries: &[&str],
111        url: &str,
112        version: Option<&str>,
113    ) -> Result<Option<Download>, anyhow::Error> {
114        let dirname = match version {
115            Some(version) => get_dirname(name, version),
116            None => hashed_dirname(url, name),
117        };
118
119        let destination = self.destination.join(&dirname);
120
121        let flock = File::create(self.destination.join(&format!(".{}.lock", dirname)))?;
122        flock.lock_exclusive()?;
123
124        if destination.exists() {
125            return Ok(Some(Download { root: destination }));
126        }
127
128        if !install_permitted {
129            return Ok(None);
130        }
131
132        let data = curl(&url).with_context(|| format!("failed to download from {}", url))?;
133
134        // Extract everything in a temporary directory in case we're ctrl-c'd.
135        // Don't want to leave around corrupted data!
136        let temp = self.destination.join(&format!(".{}", dirname));
137        drop(fs::remove_dir_all(&temp));
138        fs::create_dir_all(&temp)?;
139
140        if url.ends_with(".tar.gz") {
141            self.extract_tarball(&data, &temp, binaries)
142                .with_context(|| format!("failed to extract tarball from {}", url))?;
143        } else if url.ends_with(".zip") {
144            self.extract_zip(&data, &temp, binaries)
145                .with_context(|| format!("failed to extract zip from {}", url))?;
146        } else {
147            // panic instead of runtime error as it's a static violation to
148            // download a different kind of url, all urls should be encoded into
149            // the binary anyway
150            panic!("don't know how to extract {}", url)
151        }
152
153        // Now that everything is ready move this over to our destination and
154        // we're good to go.
155        fs::rename(&temp, &destination)?;
156
157        flock.unlock()?;
158        Ok(Some(Download { root: destination }))
159    }
160
161    /// Downloads a tarball from the specified url, extracting it locally and
162    /// returning the directory that the contents were extracted into.
163    ///
164    /// Similar to download; use this function for languages that doesn't emit a
165    /// binary.
166    pub fn download_artifact(
167        &self,
168        name: &str,
169        url: &str,
170    ) -> Result<Option<Download>, anyhow::Error> {
171        self._download_artifact(name, url, None)
172    }
173
174    /// Downloads a tarball from the specified url, extracting it locally and
175    /// returning the directory that the contents were extracted into.
176    ///
177    /// Similar to download; use this function for languages that doesn't emit a
178    /// binary.
179    pub fn download_artifact_version(
180        &self,
181        name: &str,
182        url: &str,
183        version: &str,
184    ) -> Result<Option<Download>, anyhow::Error> {
185        self._download_artifact(name, url, Some(version))
186    }
187
188    fn _download_artifact(
189        &self,
190        name: &str,
191        url: &str,
192        version: Option<&str>,
193    ) -> Result<Option<Download>, anyhow::Error> {
194        let dirname = match version {
195            Some(version) => get_dirname(name, version),
196            None => hashed_dirname(url, name),
197        };
198        let destination = self.destination.join(&dirname);
199
200        if destination.exists() {
201            return Ok(Some(Download { root: destination }));
202        }
203
204        let data = curl(&url).with_context(|| format!("failed to download from {}", url))?;
205
206        // Extract everything in a temporary directory in case we're ctrl-c'd.
207        // Don't want to leave around corrupted data!
208        let temp = self.destination.join(&format!(".{}", &dirname));
209        drop(fs::remove_dir_all(&temp));
210        fs::create_dir_all(&temp)?;
211
212        if url.ends_with(".tar.gz") {
213            self.extract_tarball_all(&data, &temp)
214                .with_context(|| format!("failed to extract tarball from {}", url))?;
215        } else {
216            // panic instead of runtime error as it's a static violation to
217            // download a different kind of url, all urls should be encoded into
218            // the binary anyway
219            panic!("don't know how to extract {}", url)
220        }
221
222        // Now that everything is ready move this over to our destination and
223        // we're good to go.
224        fs::rename(&temp, &destination)?;
225        Ok(Some(Download { root: destination }))
226    }
227
228    /// simiar to extract_tarball, but preserves all the archive's content.
229    fn extract_tarball_all(&self, tarball: &[u8], dst: &Path) -> Result<(), anyhow::Error> {
230        let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(tarball));
231
232        for entry in archive.entries()? {
233            let mut entry = entry?;
234            let dest = match entry.path()?.file_stem() {
235                Some(_) => dst.join(entry.path()?.file_name().unwrap()),
236                _ => continue,
237            };
238            entry.unpack(dest)?;
239        }
240
241        Ok(())
242    }
243
244    fn extract_tarball(
245        &self,
246        tarball: &[u8],
247        dst: &Path,
248        binaries: &[&str],
249    ) -> Result<(), anyhow::Error> {
250        let mut binaries: HashSet<_> = binaries.into_iter().map(ffi::OsStr::new).collect();
251        let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(tarball));
252
253        for entry in archive.entries()? {
254            let mut entry = entry?;
255
256            let dest = match entry.path()?.file_stem() {
257                Some(f) if binaries.contains(f) => {
258                    binaries.remove(f);
259                    dst.join(entry.path()?.file_name().unwrap())
260                }
261                _ => continue,
262            };
263
264            entry.unpack(dest)?;
265        }
266
267        if !binaries.is_empty() {
268            anyhow::bail!(
269                "the tarball was missing expected executables: {}",
270                binaries
271                    .into_iter()
272                    .map(|s| s.to_string_lossy())
273                    .collect::<Vec<_>>()
274                    .join(", "),
275            )
276        }
277
278        Ok(())
279    }
280
281    fn extract_zip(&self, zip: &[u8], dst: &Path, binaries: &[&str]) -> Result<(), anyhow::Error> {
282        let mut binaries: HashSet<_> = binaries.into_iter().map(ffi::OsStr::new).collect();
283
284        let data = io::Cursor::new(zip);
285        let mut zip = zip::ZipArchive::new(data)?;
286
287        for i in 0..zip.len() {
288            let mut entry = zip.by_index(i).unwrap();
289            let entry_path = entry.sanitized_name();
290            match entry_path.file_stem() {
291                Some(f) if binaries.contains(f) => {
292                    binaries.remove(f);
293                    let mut dest = bin_open_options()
294                        .write(true)
295                        .create_new(true)
296                        .open(dst.join(entry_path.file_name().unwrap()))?;
297                    io::copy(&mut entry, &mut dest)?;
298                }
299                _ => continue,
300            };
301        }
302
303        if !binaries.is_empty() {
304            anyhow::bail!(
305                "the zip was missing expected executables: {}",
306                binaries
307                    .into_iter()
308                    .map(|s| s.to_string_lossy())
309                    .collect::<Vec<_>>()
310                    .join(", "),
311            )
312        }
313
314        return Ok(());
315
316        #[cfg(unix)]
317        fn bin_open_options() -> fs::OpenOptions {
318            use std::os::unix::fs::OpenOptionsExt;
319
320            let mut opts = fs::OpenOptions::new();
321            opts.mode(0o755);
322            opts
323        }
324
325        #[cfg(not(unix))]
326        fn bin_open_options() -> fs::OpenOptions {
327            fs::OpenOptions::new()
328        }
329    }
330}
331
332impl Download {
333    /// Manually constructs a download at the specified path
334    pub fn at(path: &Path) -> Download {
335        Download {
336            root: path.to_path_buf(),
337        }
338    }
339
340    /// Returns the path to the binary `name` within this download
341    pub fn binary(&self, name: &str) -> Result<PathBuf, anyhow::Error> {
342        use is_executable::IsExecutable;
343
344        let ret = self
345            .root
346            .join(name)
347            .with_extension(env::consts::EXE_EXTENSION);
348
349        if !ret.is_file() {
350            anyhow::bail!("{} binary does not exist", ret.display());
351        }
352        if !ret.is_executable() {
353            anyhow::bail!("{} is not executable", ret.display());
354        }
355
356        Ok(ret)
357    }
358
359    /// Returns the path to the root
360    pub fn path(&self) -> PathBuf {
361        self.root.clone()
362    }
363}
364
365fn curl(url: &str) -> Result<Vec<u8>, anyhow::Error> {
366    let mut data = Vec::new();
367
368    let mut easy = curl::easy::Easy::new();
369    easy.follow_location(true)?;
370    easy.url(url)?;
371    easy.get(true)?;
372    {
373        let mut transfer = easy.transfer();
374        transfer.write_function(|part| {
375            data.extend_from_slice(part);
376            Ok(part.len())
377        })?;
378        transfer.perform()?;
379    }
380
381    let status_code = easy.response_code()?;
382    if 200 <= status_code && status_code < 300 {
383        Ok(data)
384    } else {
385        anyhow::bail!(
386            "received a bad HTTP status code ({}) when requesting {}",
387            status_code,
388            url
389        )
390    }
391}
392
393fn get_dirname(name: &str, suffix: &str) -> String {
394    format!("{}-{}", name, suffix)
395}
396
397fn hashed_dirname(url: &str, name: &str) -> String {
398    let mut hasher = SipHasher13::new();
399    url.hash(&mut hasher);
400    let result = hasher.finish();
401    let hex = hex::encode(&[
402        (result >> 0) as u8,
403        (result >> 8) as u8,
404        (result >> 16) as u8,
405        (result >> 24) as u8,
406        (result >> 32) as u8,
407        (result >> 40) as u8,
408        (result >> 48) as u8,
409        (result >> 56) as u8,
410    ]);
411    format!("{}-{}", name, hex)
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn it_returns_same_hash_for_same_name_and_url() {
420        let name = "wasm-pack";
421        let url = "http://localhost:7878/wasm-pack-v0.6.0.tar.gz";
422
423        let first = hashed_dirname(url, name);
424        let second = hashed_dirname(url, name);
425
426        assert!(!first.is_empty());
427        assert!(!second.is_empty());
428        assert_eq!(first, second);
429    }
430
431    #[test]
432    fn it_returns_different_hashes_for_different_urls() {
433        let name = "wasm-pack";
434        let url = "http://localhost:7878/wasm-pack-v0.5.1.tar.gz";
435        let second_url = "http://localhost:7878/wasm-pack-v0.6.0.tar.gz";
436
437        let first = hashed_dirname(url, name);
438        let second = hashed_dirname(second_url, name);
439
440        assert_ne!(first, second);
441    }
442
443    #[test]
444    fn it_returns_same_dirname_for_same_name_and_version() {
445        let name = "wasm-pack";
446        let version = "0.6.0";
447
448        let first = get_dirname(name, version);
449        let second = get_dirname(name, version);
450
451        assert!(!first.is_empty());
452        assert!(!second.is_empty());
453        assert_eq!(first, second);
454    }
455
456    #[test]
457    fn it_returns_different_dirnames_for_different_versions() {
458        let name = "wasm-pack";
459        let version = "0.5.1";
460        let second_version = "0.6.0";
461
462        let first = get_dirname(name, version);
463        let second = get_dirname(name, second_version);
464
465        assert_ne!(first, second);
466    }
467
468    #[test]
469    fn it_returns_cache_dir() {
470        let name = "wasm-pack";
471        let cache = Cache::new(name);
472
473        let expected = dirs::cache_dir()
474            .unwrap()
475            .join(PathBuf::from(".".to_owned() + name));
476
477        assert!(cache.is_ok());
478        assert_eq!(cache.unwrap().destination, expected);
479    }
480
481    #[test]
482    fn it_returns_destination_if_binary_already_exists() {
483        use std::fs;
484
485        let binary_name = "wasm-pack";
486        let binaries = vec![binary_name];
487
488        let dir = tempfile::TempDir::new().unwrap();
489        let cache = Cache::at(dir.path());
490        let version = "0.6.0";
491        let url = &format!(
492            "{}/{}/v{}.tar.gz",
493            "http://localhost:7878", binary_name, version
494        );
495
496        let dirname = get_dirname(&binary_name, &version);
497        let full_path = dir.path().join(dirname);
498
499        // Create temporary directory and binary to simulate that
500        // a cached binary already exists.
501        fs::create_dir_all(full_path).unwrap();
502
503        let dl = cache.download_version(true, binary_name, &binaries, url, version);
504
505        assert!(dl.is_ok());
506        assert!(dl.unwrap().is_some())
507    }
508}