1use 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#[derive(Debug)]
18pub struct Cache {
19 pub destination: PathBuf,
20}
21
22#[derive(Debug, Clone)]
24pub struct Download {
25 root: PathBuf,
26}
27
28impl Cache {
29 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 pub fn at(path: &Path) -> Cache {
51 Cache {
52 destination: path.to_path_buf(),
53 }
54 }
55
56 pub fn join(&self, path: &Path) -> PathBuf {
58 self.destination.join(path)
59 }
60
61 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 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 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(
167 &self,
168 name: &str,
169 url: &str,
170 ) -> Result<Option<Download>, anyhow::Error> {
171 self._download_artifact(name, url, None)
172 }
173
174 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 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!("don't know how to extract {}", url)
220 }
221
222 fs::rename(&temp, &destination)?;
225 Ok(Some(Download { root: destination }))
226 }
227
228 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 pub fn at(path: &Path) -> Download {
335 Download {
336 root: path.to_path_buf(),
337 }
338 }
339
340 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 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 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}