binary_install/
lib.rs

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