304 lines
		
	
	
		
			9.8 KiB
		
	
	
	
		
			Rust
		
	
	
	
	
	
			
		
		
	
	
			304 lines
		
	
	
		
			9.8 KiB
		
	
	
	
		
			Rust
		
	
	
	
	
	
| use futures_util::StreamExt;
 | |
| use log::{debug, info, warn};
 | |
| use sha2::{Digest, Sha256};
 | |
| use std::io::Read;
 | |
| use std::path::PathBuf;
 | |
| use tokio::fs;
 | |
| use tokio::fs::File;
 | |
| use tokio::io::AsyncWriteExt;
 | |
| use url::Url;
 | |
| 
 | |
| const CHECKSUM_FAILED_MSG: &str = "Downloaded file failed checksum verification";
 | |
| 
 | |
| /// Represents an asset that can be downloaded from a URL with checksum verification.
 | |
| ///
 | |
| /// This struct facilitates secure downloading of files from remote URLs by
 | |
| /// verifying the integrity of the downloaded content using SHA-256 checksums.
 | |
| /// It handles downloading the file, saving it to disk, and verifying the checksum matches
 | |
| /// the expected value.
 | |
| ///
 | |
| /// # Examples
 | |
| ///
 | |
| /// ```compile_fail
 | |
| /// # use url::Url;
 | |
| /// # use std::path::PathBuf;
 | |
| ///
 | |
| /// # async fn example() -> Result<(), String> {
 | |
| /// let asset = DownloadableAsset {
 | |
| ///     url: Url::parse("https://example.com/file.zip").unwrap(),
 | |
| ///     file_name: "file.zip".to_string(),
 | |
| ///     checksum: "a1b2c3d4e5f6...".to_string(),
 | |
| /// };
 | |
| ///
 | |
| /// let download_dir = PathBuf::from("/tmp/downloads");
 | |
| /// let file_path = asset.download_to_path(download_dir).await?;
 | |
| /// # Ok(())
 | |
| /// # }
 | |
| /// ```
 | |
| #[derive(Debug)]
 | |
| pub(crate) struct DownloadableAsset {
 | |
|     pub(crate) url: Url,
 | |
|     pub(crate) file_name: String,
 | |
|     pub(crate) checksum: String,
 | |
| }
 | |
| 
 | |
| impl DownloadableAsset {
 | |
|     fn verify_checksum(&self, file: PathBuf) -> bool {
 | |
|         if !file.exists() {
 | |
|             warn!("File does not exist: {:?}", file);
 | |
|             return false;
 | |
|         }
 | |
| 
 | |
|         let mut file = match std::fs::File::open(&file) {
 | |
|             Ok(file) => file,
 | |
|             Err(e) => {
 | |
|                 warn!("Failed to open file for checksum verification: {:?}", e);
 | |
|                 return false;
 | |
|             }
 | |
|         };
 | |
| 
 | |
|         let mut hasher = Sha256::new();
 | |
|         let mut buffer = [0; 1024 * 1024]; // 1MB buffer
 | |
| 
 | |
|         loop {
 | |
|             let bytes_read = match file.read(&mut buffer) {
 | |
|                 Ok(0) => break,
 | |
|                 Ok(n) => n,
 | |
|                 Err(e) => {
 | |
|                     warn!("Error reading file for checksum: {:?}", e);
 | |
|                     return false;
 | |
|                 }
 | |
|             };
 | |
| 
 | |
|             hasher.update(&buffer[..bytes_read]);
 | |
|         }
 | |
| 
 | |
|         let result = hasher.finalize();
 | |
|         let calculated_hash = format!("{:x}", result);
 | |
| 
 | |
|         debug!("Expected checksum: {}", self.checksum);
 | |
|         debug!("Calculated checksum: {}", calculated_hash);
 | |
| 
 | |
|         calculated_hash == self.checksum
 | |
|     }
 | |
| 
 | |
|     /// Downloads the asset to the specified directory, verifying its checksum.
 | |
|     ///
 | |
|     /// This function will:
 | |
|     /// 1. Create the target directory if it doesn't exist
 | |
|     /// 2. Check if the file already exists with the correct checksum
 | |
|     /// 3. If not, download the file from the URL
 | |
|     /// 4. Verify the downloaded file's checksum matches the expected value
 | |
|     ///
 | |
|     /// # Arguments
 | |
|     ///
 | |
|     /// * `folder` - The directory path where the file should be saved
 | |
|     ///
 | |
|     /// # Returns
 | |
|     ///
 | |
|     /// * `Ok(PathBuf)` - The path to the downloaded file on success
 | |
|     /// * `Err(String)` - A descriptive error message if the download or verification fails
 | |
|     ///
 | |
|     /// # Errors
 | |
|     ///
 | |
|     /// This function will return an error if:
 | |
|     /// - The network request fails
 | |
|     /// - The server responds with a non-success status code
 | |
|     /// - Writing to disk fails
 | |
|     /// - The checksum verification fails
 | |
|     pub(crate) async fn download_to_path(&self, folder: PathBuf) -> Result<PathBuf, String> {
 | |
|         if !folder.exists() {
 | |
|             fs::create_dir_all(&folder)
 | |
|                 .await
 | |
|                 .expect("Failed to create download directory");
 | |
|         }
 | |
| 
 | |
|         let target_file_path = folder.join(&self.file_name);
 | |
|         debug!("Downloading to path: {:?}", target_file_path);
 | |
| 
 | |
|         if self.verify_checksum(target_file_path.clone()) {
 | |
|             debug!("File already exists with correct checksum, skipping download");
 | |
|             return Ok(target_file_path);
 | |
|         }
 | |
| 
 | |
|         debug!("Downloading from URL: {}", self.url);
 | |
|         let client = reqwest::Client::new();
 | |
|         let response = client
 | |
|             .get(self.url.clone())
 | |
|             .send()
 | |
|             .await
 | |
|             .map_err(|e| format!("Failed to download file: {e}"))?;
 | |
| 
 | |
|         if !response.status().is_success() {
 | |
|             return Err(format!(
 | |
|                 "Failed to download file, status: {}",
 | |
|                 response.status()
 | |
|             ));
 | |
|         }
 | |
| 
 | |
|         let mut file = File::create(&target_file_path)
 | |
|             .await
 | |
|             .expect("Failed to create target file");
 | |
| 
 | |
|         let mut stream = response.bytes_stream();
 | |
|         while let Some(chunk_result) = stream.next().await {
 | |
|             let chunk = chunk_result.expect("Error while downloading file");
 | |
|             file.write_all(&chunk)
 | |
|                 .await
 | |
|                 .expect("Failed to write data to file");
 | |
|         }
 | |
| 
 | |
|         file.flush().await.expect("Failed to flush file");
 | |
|         drop(file);
 | |
| 
 | |
|         if !self.verify_checksum(target_file_path.clone()) {
 | |
|             return Err(CHECKSUM_FAILED_MSG.to_string());
 | |
|         }
 | |
| 
 | |
|         info!(
 | |
|             "File downloaded and verified successfully: {}",
 | |
|             target_file_path.to_string_lossy()
 | |
|         );
 | |
|         Ok(target_file_path)
 | |
|     }
 | |
| }
 | |
| 
 | |
| #[cfg(test)]
 | |
| mod tests {
 | |
|     use super::*;
 | |
|     use httptest::{
 | |
|         matchers::{self, request},
 | |
|         responders, Expectation, Server,
 | |
|     };
 | |
| 
 | |
|     const BASE_TEST_PATH: &str = "/tmp/harmony-test-k3d-download";
 | |
|     const TEST_CONTENT: &str = "This is a test file.";
 | |
|     const TEST_CONTENT_HASH: &str =
 | |
|         "f29bc64a9d3732b4b9035125fdb3285f5b6455778edca72414671e0ca3b2e0de";
 | |
| 
 | |
|     fn setup_test() -> (PathBuf, Server) {
 | |
|         let _ = env_logger::builder().try_init();
 | |
| 
 | |
|         // Create unique test directory
 | |
|         let test_id = std::time::SystemTime::now()
 | |
|             .duration_since(std::time::UNIX_EPOCH)
 | |
|             .unwrap()
 | |
|             .as_millis();
 | |
|         let download_path = format!("{}/test_{}", BASE_TEST_PATH, test_id);
 | |
|         std::fs::create_dir_all(&download_path).unwrap();
 | |
| 
 | |
|         (PathBuf::from(download_path), Server::run())
 | |
|     }
 | |
| 
 | |
|     #[tokio::test]
 | |
|     async fn test_download_to_path_success() {
 | |
|         let (folder, server) = setup_test();
 | |
| 
 | |
|         server.expect(
 | |
|             Expectation::matching(request::method_path("GET", "/test.txt"))
 | |
|                 .respond_with(responders::status_code(200).body(TEST_CONTENT)),
 | |
|         );
 | |
| 
 | |
|         let asset = DownloadableAsset {
 | |
|             url: Url::parse(&server.url("/test.txt").to_string()).unwrap(),
 | |
|             file_name: "test.txt".to_string(),
 | |
|             checksum: TEST_CONTENT_HASH.to_string(),
 | |
|         };
 | |
| 
 | |
|         let result = asset
 | |
|             .download_to_path(folder.join("success"))
 | |
|             .await
 | |
|             .unwrap();
 | |
|         let downloaded_content = std::fs::read_to_string(result).unwrap();
 | |
|         assert_eq!(downloaded_content, TEST_CONTENT);
 | |
|     }
 | |
| 
 | |
|     #[tokio::test]
 | |
|     async fn test_download_to_path_already_exists() {
 | |
|         let (folder, server) = setup_test();
 | |
| 
 | |
|         server.expect(
 | |
|             Expectation::matching(matchers::any())
 | |
|                 .times(0)
 | |
|                 .respond_with(responders::status_code(200).body(TEST_CONTENT)),
 | |
|         );
 | |
| 
 | |
|         let asset = DownloadableAsset {
 | |
|             url: Url::parse(&server.url("/test.txt").to_string()).unwrap(),
 | |
|             file_name: "test.txt".to_string(),
 | |
|             checksum: TEST_CONTENT_HASH.to_string(),
 | |
|         };
 | |
| 
 | |
|         let target_file_path = folder.join(&asset.file_name);
 | |
|         std::fs::write(&target_file_path, TEST_CONTENT).unwrap();
 | |
| 
 | |
|         let result = asset.download_to_path(folder).await.unwrap();
 | |
|         let content = std::fs::read_to_string(result).unwrap();
 | |
|         assert_eq!(content, TEST_CONTENT);
 | |
|     }
 | |
| 
 | |
|     #[tokio::test]
 | |
|     async fn test_download_to_path_server_error() {
 | |
|         let (folder, server) = setup_test();
 | |
| 
 | |
|         server.expect(
 | |
|             Expectation::matching(matchers::any()).respond_with(responders::status_code(404)),
 | |
|         );
 | |
| 
 | |
|         let asset = DownloadableAsset {
 | |
|             url: Url::parse(&server.url("/test.txt").to_string()).unwrap(),
 | |
|             file_name: "test.txt".to_string(),
 | |
|             checksum: TEST_CONTENT_HASH.to_string(),
 | |
|         };
 | |
| 
 | |
|         let result = asset.download_to_path(folder.join("error")).await;
 | |
|         assert!(result.is_err());
 | |
|         assert!(result.unwrap_err().contains("status: 404"));
 | |
|     }
 | |
| 
 | |
|     #[tokio::test]
 | |
|     async fn test_download_to_path_checksum_failure() {
 | |
|         let (folder, server) = setup_test();
 | |
| 
 | |
|         let invalid_content = "This is NOT the expected content";
 | |
|         server.expect(
 | |
|             Expectation::matching(matchers::any())
 | |
|                 .respond_with(responders::status_code(200).body(invalid_content)),
 | |
|         );
 | |
| 
 | |
|         let asset = DownloadableAsset {
 | |
|             url: Url::parse(&server.url("/test.txt").to_string()).unwrap(),
 | |
|             file_name: "test.txt".to_string(),
 | |
|             checksum: TEST_CONTENT_HASH.to_string(),
 | |
|         };
 | |
| 
 | |
|         let join_handle =
 | |
|             tokio::spawn(async move { asset.download_to_path(folder.join("failure")).await });
 | |
| 
 | |
|         assert_eq!(
 | |
|             join_handle.await.unwrap().err().unwrap(),
 | |
|             CHECKSUM_FAILED_MSG
 | |
|         );
 | |
|     }
 | |
| 
 | |
|     #[tokio::test]
 | |
|     async fn test_download_with_specific_path_matcher() {
 | |
|         let (folder, server) = setup_test();
 | |
| 
 | |
|         server.expect(
 | |
|             Expectation::matching(matchers::request::path("/specific/path.txt"))
 | |
|                 .respond_with(responders::status_code(200).body(TEST_CONTENT)),
 | |
|         );
 | |
| 
 | |
|         let asset = DownloadableAsset {
 | |
|             url: Url::parse(&server.url("/specific/path.txt").to_string()).unwrap(),
 | |
|             file_name: "path.txt".to_string(),
 | |
|             checksum: TEST_CONTENT_HASH.to_string(),
 | |
|         };
 | |
| 
 | |
|         let result = asset.download_to_path(folder).await.unwrap();
 | |
|         let downloaded_content = std::fs::read_to_string(result).unwrap();
 | |
|         assert_eq!(downloaded_content, TEST_CONTENT);
 | |
|     }
 | |
| }
 |