1use 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#[derive(Debug)]
17pub struct Cache {
18 pub destination: PathBuf,
19}
20
21#[derive(Debug, Clone)]
23pub struct Download {
24 root: PathBuf,
25}
26
27impl Cache {
28 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 pub fn at(path: &Path) -> Cache {
50 Cache {
51 destination: path.to_path_buf(),
52 }
53 }
54
55 pub fn join(&self, path: &Path) -> PathBuf {
57 self.destination.join(path)
58 }
59
60 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 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 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!("don't know how to extract {}", url)
151 }
152
153 fs::rename(&temp, &destination)?;
156
157 flock.unlock()?;
158 Ok(Some(Download { root: destination }))
159 }
160
161 pub fn download_artifact(&self, name: &str, url: &str) -> Result<Option<Download>> {
167 self._download_artifact(name, url, None)
168 }
169
170 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 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!("don't know how to extract {}", url)
217 }
218
219 fs::rename(&temp, &destination)?;
222 Ok(Some(Download { root: destination }))
223 }
224
225 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 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 pub fn at(path: &Path) -> Download {
361 Download {
362 root: path.to_path_buf(),
363 }
364 }
365
366 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 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 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 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}