Skip to main content

kget/
download.rs

1//! Simple HTTP/HTTPS download functionality.
2//!
3//! This module provides basic download capabilities with automatic retry,
4//! progress tracking, and ISO integrity verification.
5//!
6//! For advanced features like parallel connections and resume support,
7//! see [`AdvancedDownloader`](crate::AdvancedDownloader).
8//!
9//! # Example
10//!
11//! ```rust,no_run
12//! use kget::{download, DownloadOptions, ProxyConfig, Optimizer};
13//!
14//! let options = DownloadOptions {
15//!     quiet_mode: false,
16//!     output_path: Some("./file.zip".to_string()),
17//!     verify_iso: false,
18//!     expected_sha256: None,
19//! };
20//!
21//! download(
22//!     "https://example.com/file.zip",
23//!     ProxyConfig::default(),
24//!     Optimizer::new(),
25//!     options,
26//!     None,
27//! ).unwrap();
28//! ```
29
30use crate::DownloadOptions;
31use crate::config::ProxyConfig;
32use crate::optimization::Optimizer;
33use crate::progress::create_progress_bar;
34use crate::utils::{self, print};
35use humansize::{DECIMAL, format_size};
36use mime::Mime;
37use reqwest::blocking::Client;
38use reqwest::header::{CONTENT_LENGTH, CONTENT_TYPE};
39use sha2::Digest;
40use std::error::Error;
41use std::fs::File;
42use std::io::{Read, Write};
43use std::path::{Path, PathBuf};
44use std::time::Duration;
45
46const MAX_RETRIES: u32 = 3;
47const RETRY_DELAY: Duration = Duration::from_secs(2);
48
49/// Check if there's enough disk space for the download.
50///
51/// # Arguments
52///
53/// * `path` - Target file path
54/// * `required_size` - Required space in bytes
55///
56/// # Errors
57///
58/// Returns an error if available space is less than required.
59pub fn check_disk_space(
60    path: &Path,
61    required_size: u64,
62) -> Result<(), Box<dyn Error + Send + Sync>> {
63    let dir = path.parent().unwrap_or(Path::new("."));
64    let available_space = fs2::available_space(dir)?;
65
66    if available_space < required_size {
67        return Err(format!(
68            "Insufficient disk space. Required: {}, Available: {}",
69            format_size(required_size, DECIMAL),
70            format_size(available_space, DECIMAL)
71        )
72        .into());
73    }
74    Ok(())
75}
76
77/// Validate that a filename is safe and valid.
78///
79/// # Errors
80///
81/// Returns an error if the filename:
82/// - Contains directory separators
83/// - Is empty
84pub fn validate_filename(filename: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
85    if filename.contains('/') || filename.contains('\\') {
86        return Err("Filename cannot contain directory separators".into());
87    }
88    if filename.is_empty() {
89        return Err("Filename cannot be empty".into());
90    }
91    Ok(())
92}
93
94/// Download a file from a URL with automatic retry and progress tracking.
95///
96/// This is the simple download function for basic use cases. For parallel
97/// connections and resume support, use [`AdvancedDownloader`](crate::AdvancedDownloader).
98///
99/// # Arguments
100///
101/// * `target` - URL to download
102/// * `proxy` - Proxy configuration (use `ProxyConfig::default()` for no proxy)
103/// * `_optimizer` - Optimizer instance (reserved for future use)
104/// * `options` - Download options (quiet mode, output path, ISO verification)
105/// * `status_callback` - Optional callback for status messages
106///
107/// # Example
108///
109/// ```rust,no_run
110/// use kget::{download, DownloadOptions, ProxyConfig, Optimizer};
111///
112/// download(
113///     "https://releases.ubuntu.com/22.04/ubuntu-22.04-desktop-amd64.iso",
114///     ProxyConfig::default(),
115///     Optimizer::new(),
116///     DownloadOptions {
117///         quiet_mode: false,
118///         output_path: None, // Uses filename from URL
119///         verify_iso: true,  // Verify SHA256 after download
120///         expected_sha256: None,
121///     },
122///     None,
123/// ).unwrap();
124/// ```
125///
126/// # Errors
127///
128/// Returns an error if:
129/// - Network connection fails after MAX_RETRIES attempts
130/// - HTTP response indicates an error
131/// - Insufficient disk space
132/// - File cannot be created
133pub fn download(
134    target: &str,
135    proxy: ProxyConfig,
136    _optimizer: Optimizer,
137    options: DownloadOptions,
138    status_callback: Option<&(dyn Fn(String) + Send + Sync)>,
139) -> Result<(), Box<dyn Error + Send + Sync>> {
140    let quiet_mode = options.quiet_mode;
141
142    let mut client_builder = Client::builder()
143        .timeout(Duration::from_secs(30))
144        .no_gzip()
145        .no_deflate();
146
147    if proxy.enabled {
148        if let Some(proxy_url) = &proxy.url {
149            let proxy_client = match proxy.proxy_type {
150                crate::config::ProxyType::Http => reqwest::Proxy::http(proxy_url),
151                crate::config::ProxyType::Https => reqwest::Proxy::https(proxy_url),
152                crate::config::ProxyType::Socks5 => reqwest::Proxy::all(proxy_url),
153            };
154            if let Ok(mut proxy_client) = proxy_client {
155                if let (Some(username), Some(password)) = (&proxy.username, &proxy.password) {
156                    proxy_client = proxy_client.basic_auth(username, password);
157                }
158                client_builder = client_builder.proxy(proxy_client);
159            }
160        }
161    }
162
163    let client = client_builder.build()?;
164
165    let mut retries = 0;
166    let response = loop {
167        match client.get(target).send() {
168            Ok(resp) => break resp,
169            Err(e) => {
170                retries += 1;
171                if retries >= MAX_RETRIES {
172                    return Err(format!("Failed after {} attempts: {}", MAX_RETRIES, e).into());
173                }
174                print(
175                    &format!(
176                        "Attempt {} failed, retrying in {} seconds...",
177                        retries,
178                        RETRY_DELAY.as_secs()
179                    ),
180                    quiet_mode,
181                );
182                std::thread::sleep(RETRY_DELAY);
183            }
184        }
185    };
186
187    print(
188        &format!("HTTP request sent... {}", response.status()),
189        quiet_mode,
190    );
191
192    if !response.status().is_success() {
193        return Err(format!("HTTP error: {}", response.status()).into());
194    }
195
196    let content_length = response
197        .headers()
198        .get(CONTENT_LENGTH)
199        .and_then(|ct_len| ct_len.to_str().ok())
200        .and_then(|s| s.parse::<u64>().ok());
201
202    let content_type = response
203        .headers()
204        .get(CONTENT_TYPE)
205        .and_then(|ct| ct.to_str().ok())
206        .and_then(|s| s.parse::<Mime>().ok());
207
208    if let Some(len) = content_length {
209        print(
210            &format!("Length: {} ({})", len, format_size(len, DECIMAL)),
211            quiet_mode,
212        );
213    } else {
214        print("Length: unknown", quiet_mode);
215    }
216
217    if let Some(ref ct) = content_type {
218        print(&format!("Type: {}", ct), quiet_mode);
219    }
220
221    let is_iso = target.to_lowercase().ends_with(".iso")
222        || content_type.as_ref().map_or(false, |ct| {
223            ct.essence_str() == "application/x-iso9001"
224                || ct.essence_str() == "application/x-cd-image"
225        });
226
227    if is_iso {
228        print(
229            "ISO file detected. Ensuring raw download to prevent corruption...",
230            quiet_mode,
231        );
232    }
233
234    let tentative_path: PathBuf;
235
236    if let Some(output_arg_str) = options.output_path {
237        let user_path = PathBuf::from(output_arg_str.clone());
238
239        let is_target_dir =
240            user_path.is_dir() || output_arg_str.ends_with(std::path::MAIN_SEPARATOR);
241
242        if is_target_dir {
243            let base_filename = utils::get_filename_from_url_or_default(target, "downloaded_file");
244            validate_filename(&base_filename)?;
245            tentative_path = user_path.join(base_filename);
246        } else {
247            if let Some(file_name_osstr) = user_path.file_name() {
248                if let Some(file_name_str) = file_name_osstr.to_str() {
249                    if file_name_str.is_empty() {
250                        return Err(format!(
251                            "Invalid output path, does not specify a file name: {}",
252                            user_path.display()
253                        )
254                        .into());
255                    }
256                    validate_filename(file_name_str)?;
257                } else {
258                    return Err("Output filename contains invalid characters (non-UTF-8)".into());
259                }
260            } else {
261                return Err(format!(
262                    "Invalid output path, does not specify a file name: {}",
263                    user_path.display()
264                )
265                .into());
266            }
267            tentative_path = user_path;
268        }
269    } else {
270        let base_filename = utils::get_filename_from_url_or_default(target, "downloaded_file");
271        validate_filename(&base_filename)?;
272        tentative_path = PathBuf::from(base_filename);
273    }
274
275    let final_path: PathBuf = if tentative_path.is_absolute() {
276        tentative_path
277    } else {
278        let current_dir = std::env::current_dir()
279            .map_err(|e| format!("Failed to get current directory: {}", e))?;
280        current_dir.join(tentative_path)
281    };
282
283    if let Some(parent_dir) = final_path.parent() {
284        if !parent_dir.as_os_str().is_empty()
285            && parent_dir != Path::new("/")
286            && !parent_dir.exists()
287        {
288            std::fs::create_dir_all(parent_dir).map_err(|e| {
289                format!("Failed to create directory {}: {}", parent_dir.display(), e)
290            })?;
291            if !quiet_mode {
292                print(
293                    &format!("Created directory: {}", parent_dir.display()),
294                    quiet_mode,
295                );
296            }
297        }
298    }
299
300    if !quiet_mode {
301        print(&format!("Saving to: {}", final_path.display()), quiet_mode);
302    }
303
304    if let Some(len) = content_length {
305        check_disk_space(&final_path, len)?;
306    }
307
308    let mut dest = File::create(&final_path)
309        .map_err(|e| format!("Failed to create file {}: {}", final_path.display(), e))?;
310
311    let response_content_length = response.content_length();
312    let progress_bar_filename = final_path
313        .file_name()
314        .unwrap_or_default()
315        .to_string_lossy()
316        .into_owned();
317    let progress = create_progress_bar(
318        quiet_mode,
319        progress_bar_filename,
320        response_content_length,
321        false,
322    );
323
324    let mut source = response.take(response_content_length.unwrap_or(u64::MAX));
325    let mut buffered_reader = progress.wrap_read(&mut source);
326
327    // Stream data instead of reading all into memory
328    let mut buffer = [0u8; 8192];
329    loop {
330        let n = buffered_reader.read(&mut buffer)?;
331        if n == 0 {
332            break;
333        }
334        dest.write_all(&buffer[..n])?;
335    }
336
337    progress.finish_with_message("Download completed\n");
338
339    if is_iso && options.verify_iso {
340        verify_file_sha256(
341            &final_path,
342            options.expected_sha256.as_deref(),
343            status_callback,
344        )?;
345    } else if let Some(expected) = options.expected_sha256.as_deref() {
346        verify_file_sha256(&final_path, Some(expected), status_callback)?;
347    }
348
349    Ok(())
350}
351
352/// Verify the integrity of an ISO file by calculating its SHA-256 hash.
353///
354/// After download, this function calculates the SHA-256 checksum of the file
355/// and displays it for manual comparison with the source.
356///
357/// # Arguments
358///
359/// * `path` - Path to the ISO file
360/// * `callback` - Optional callback for status messages
361///
362/// # Example
363///
364/// ```rust,no_run
365/// use kget::verify_iso_integrity;
366/// use std::path::Path;
367///
368/// verify_iso_integrity(
369///     Path::new("ubuntu-22.04-desktop-amd64.iso"),
370///     Some(&|msg| println!("Status: {}", msg)),
371/// ).unwrap();
372/// ```
373///
374/// # Output
375///
376/// Prints the SHA256 hash to stdout and sends it via callback if provided.
377pub fn verify_iso_integrity(
378    path: &Path,
379    callback: Option<&(dyn Fn(String) + Send + Sync)>,
380) -> Result<(), Box<dyn Error + Send + Sync>> {
381    verify_file_sha256(path, None, callback).map(|_| ())
382}
383
384/// Calculate a file SHA-256 hash and optionally compare it with an expected value.
385pub fn verify_file_sha256(
386    path: &Path,
387    expected_hash: Option<&str>,
388    callback: Option<&(dyn Fn(String) + Send + Sync)>,
389) -> Result<String, Box<dyn Error + Send + Sync>> {
390    let msg = "Calculating SHA256 hash... (this may take a while for large ISOs)";
391    if let Some(cb) = callback {
392        cb(msg.to_string());
393    }
394    println!("{}", msg);
395
396    let mut file = File::open(path)?;
397    let mut hasher = sha2::Sha256::new();
398    let mut buffer = [0; 8192];
399    loop {
400        let n = file.read(&mut buffer)?;
401        if n == 0 {
402            break;
403        }
404        hasher.update(&buffer[..n]);
405    }
406    let hash = hex::encode(hasher.finalize());
407
408    let msg_done = "Integrity check finished.";
409    if let Some(cb) = callback {
410        cb(msg_done.to_string());
411    }
412    println!("{}", msg_done);
413
414    let msg_hash = format!("SHA256: {}", hash);
415    if let Some(cb) = callback {
416        cb(msg_hash.clone());
417    }
418    println!("SHA256: {}", hash);
419
420    if let Some(expected_hash) = expected_hash {
421        let expected_hash = expected_hash.trim().to_ascii_lowercase();
422        if hash != expected_hash {
423            return Err(
424                format!("SHA256 mismatch: expected {}, got {}", expected_hash, hash).into(),
425            );
426        }
427
428        let msg_match = "SHA256 matches expected hash.";
429        if let Some(cb) = callback {
430            cb(msg_match.to_string());
431        }
432        println!("{}", msg_match);
433    } else {
434        println!("You can compare this hash with the one provided by the source.");
435    }
436
437    Ok(hash)
438}