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_DISPOSITION, 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, Instant};
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/// - Is empty
83/// - Contains directory separators
84/// - Contains null bytes
85/// - Contains path traversal sequences (`..`)
86/// - Exceeds 255 bytes
87/// - Matches a Windows reserved name (CON, NUL, COM1–9, LPT1–9, etc.)
88pub fn validate_filename(filename: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
89    if filename.is_empty() {
90        return Err("Filename cannot be empty".into());
91    }
92    if filename.contains('/') || filename.contains('\\') {
93        return Err("Filename cannot contain directory separators".into());
94    }
95    if filename.contains('\0') {
96        return Err("Filename cannot contain null bytes".into());
97    }
98    if filename.contains("..") {
99        return Err("Filename cannot contain path traversal sequences".into());
100    }
101    if filename.len() > 255 {
102        return Err("Filename exceeds maximum length of 255 bytes".into());
103    }
104    // Windows reserved device names — forbidden even on Unix to stay cross-platform safe.
105    let stem = filename.split('.').next().unwrap_or(filename);
106    const RESERVED: &[&str] = &[
107        "CON", "PRN", "AUX", "NUL",
108        "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
109        "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
110    ];
111    if RESERVED.iter().any(|r| stem.eq_ignore_ascii_case(r)) {
112        return Err(format!("'{}' is a reserved filename on Windows", stem).into());
113    }
114    Ok(())
115}
116
117/// Download a file from a URL with automatic retry and progress tracking.
118///
119/// This is the simple download function for basic use cases. For parallel
120/// connections and resume support, use [`AdvancedDownloader`](crate::AdvancedDownloader).
121///
122/// # Arguments
123///
124/// * `target` - URL to download
125/// * `proxy` - Proxy configuration (use `ProxyConfig::default()` for no proxy)
126/// * `_optimizer` - Optimizer instance (reserved for future use)
127/// * `options` - Download options (quiet mode, output path, ISO verification)
128/// * `status_callback` - Optional callback for status messages
129///
130/// # Example
131///
132/// ```rust,no_run
133/// use kget::{download, DownloadOptions, ProxyConfig, Optimizer};
134///
135/// download(
136///     "https://releases.ubuntu.com/22.04/ubuntu-22.04-desktop-amd64.iso",
137///     ProxyConfig::default(),
138///     Optimizer::new(),
139///     DownloadOptions {
140///         quiet_mode: false,
141///         output_path: None, // Uses filename from URL
142///         verify_iso: true,  // Verify SHA256 after download
143///         expected_sha256: None,
144///     },
145///     None,
146/// ).unwrap();
147/// ```
148///
149/// # Errors
150///
151/// Returns an error if:
152/// - Network connection fails after MAX_RETRIES attempts
153/// - HTTP response indicates an error
154/// - Insufficient disk space
155/// - File cannot be created
156pub fn download(
157    target: &str,
158    proxy: ProxyConfig,
159    optimizer: Optimizer,
160    options: DownloadOptions,
161    status_callback: Option<&(dyn Fn(String) + Send + Sync)>,
162) -> Result<(), Box<dyn Error + Send + Sync>> {
163    let quiet_mode = options.quiet_mode;
164
165    let mut client_builder = Client::builder()
166        .timeout(Duration::from_secs(30))
167        .user_agent(concat!("KGet/", env!("CARGO_PKG_VERSION")))
168        .no_gzip()
169        .no_deflate();
170
171    if proxy.enabled {
172        if let Some(proxy_url) = &proxy.url {
173            let proxy_client = match proxy.proxy_type {
174                crate::config::ProxyType::Http => reqwest::Proxy::http(proxy_url),
175                crate::config::ProxyType::Https => reqwest::Proxy::https(proxy_url),
176                crate::config::ProxyType::Socks5 => reqwest::Proxy::all(proxy_url),
177            };
178            if let Ok(mut proxy_client) = proxy_client {
179                if let (Some(username), Some(password)) = (&proxy.username, &proxy.password) {
180                    proxy_client = proxy_client.basic_auth(username, password);
181                }
182                client_builder = client_builder.proxy(proxy_client);
183            }
184        }
185    }
186
187    let client = client_builder.build()?;
188
189    let mut retries = 0;
190    let response = loop {
191        let mut req = client.get(target);
192        for (name, value) in &options.extra_headers {
193            if let (Ok(n), Ok(v)) = (
194                reqwest::header::HeaderName::from_bytes(name.as_bytes()),
195                reqwest::header::HeaderValue::from_str(value),
196            ) {
197                req = req.header(n, v);
198            }
199        }
200        match req.send() {
201            Ok(resp) => {
202                let status = resp.status();
203                if status.is_success() {
204                    break resp;
205                } else if status.is_server_error() {
206                    // 5xx — transient, worth retrying
207                    retries += 1;
208                    if retries >= MAX_RETRIES {
209                        return Err(
210                            format!("HTTP {} after {} attempts", status, MAX_RETRIES).into()
211                        );
212                    }
213                    print(
214                        &format!(
215                            "Server error {} on attempt {}, retrying in {} seconds...",
216                            status,
217                            retries,
218                            RETRY_DELAY.as_secs()
219                        ),
220                        quiet_mode,
221                    );
222                    std::thread::sleep(RETRY_DELAY);
223                } else {
224                    // 4xx and others — permanent failure, do not retry
225                    return Err(format!("HTTP error: {}", status).into());
226                }
227            }
228            Err(e) => {
229                retries += 1;
230                if retries >= MAX_RETRIES {
231                    return Err(format!("Failed after {} attempts: {}", MAX_RETRIES, e).into());
232                }
233                print(
234                    &format!(
235                        "Attempt {} failed, retrying in {} seconds...",
236                        retries,
237                        RETRY_DELAY.as_secs()
238                    ),
239                    quiet_mode,
240                );
241                std::thread::sleep(RETRY_DELAY);
242            }
243        }
244    };
245
246    print(
247        &format!("HTTP request sent... {}", response.status()),
248        quiet_mode,
249    );
250
251    let content_length = response
252        .headers()
253        .get(CONTENT_LENGTH)
254        .and_then(|ct_len| ct_len.to_str().ok())
255        .and_then(|s| s.parse::<u64>().ok());
256
257    let content_type = response
258        .headers()
259        .get(CONTENT_TYPE)
260        .and_then(|ct| ct.to_str().ok())
261        .and_then(|s| s.parse::<Mime>().ok());
262
263    let server_filename = response
264        .headers()
265        .get(CONTENT_DISPOSITION)
266        .and_then(|v| v.to_str().ok())
267        .and_then(parse_content_disposition_filename);
268
269    if let Some(len) = content_length {
270        print(
271            &format!("Length: {} ({})", len, format_size(len, DECIMAL)),
272            quiet_mode,
273        );
274    } else {
275        print("Length: unknown", quiet_mode);
276    }
277
278    if let Some(ref ct) = content_type {
279        print(&format!("Type: {}", ct), quiet_mode);
280    }
281
282    let is_iso = target.to_lowercase().ends_with(".iso")
283        || content_type.as_ref().map_or(false, |ct| {
284            ct.essence_str() == "application/x-iso9660-image"
285                || ct.essence_str() == "application/x-cd-image"
286        });
287
288    if is_iso {
289        print(
290            "ISO file detected. Ensuring raw download to prevent corruption...",
291            quiet_mode,
292        );
293    }
294
295    let tentative_path: PathBuf;
296
297    if let Some(output_arg_str) = options.output_path {
298        let user_path = PathBuf::from(output_arg_str.clone());
299
300        let is_target_dir =
301            user_path.is_dir() || output_arg_str.ends_with(std::path::MAIN_SEPARATOR);
302
303        if is_target_dir {
304            let base_filename = utils::get_filename_from_url_or_default(target, "downloaded_file");
305            validate_filename(&base_filename)?;
306            tentative_path = user_path.join(base_filename);
307        } else {
308            if let Some(file_name_osstr) = user_path.file_name() {
309                if let Some(file_name_str) = file_name_osstr.to_str() {
310                    if file_name_str.is_empty() {
311                        return Err(format!(
312                            "Invalid output path, does not specify a file name: {}",
313                            user_path.display()
314                        )
315                        .into());
316                    }
317                    validate_filename(file_name_str)?;
318                } else {
319                    return Err("Output filename contains invalid characters (non-UTF-8)".into());
320                }
321            } else {
322                return Err(format!(
323                    "Invalid output path, does not specify a file name: {}",
324                    user_path.display()
325                )
326                .into());
327            }
328            tentative_path = user_path;
329        }
330    } else {
331        let base_filename = if let Some(ref name) = server_filename {
332            name.clone()
333        } else {
334            utils::get_filename_from_url_or_default(target, "downloaded_file")
335        };
336        validate_filename(&base_filename)?;
337        tentative_path = PathBuf::from(base_filename);
338    }
339
340    let final_path: PathBuf = if tentative_path.is_absolute() {
341        tentative_path
342    } else {
343        let current_dir = std::env::current_dir()
344            .map_err(|e| format!("Failed to get current directory: {}", e))?;
345        current_dir.join(tentative_path)
346    };
347
348    if let Some(parent_dir) = final_path.parent() {
349        if !parent_dir.as_os_str().is_empty()
350            && parent_dir != Path::new("/")
351            && !parent_dir.exists()
352        {
353            std::fs::create_dir_all(parent_dir).map_err(|e| {
354                format!("Failed to create directory {}: {}", parent_dir.display(), e)
355            })?;
356            if !quiet_mode {
357                print(
358                    &format!("Created directory: {}", parent_dir.display()),
359                    quiet_mode,
360                );
361            }
362        }
363    }
364
365    if !quiet_mode {
366        print(&format!("Saving to: {}", final_path.display()), quiet_mode);
367    }
368
369    if let Some(len) = content_length {
370        check_disk_space(&final_path, len)?;
371    }
372
373    let mut dest = File::create(&final_path)
374        .map_err(|e| format!("Failed to create file {}: {}", final_path.display(), e))?;
375
376    let response_content_length = response.content_length();
377    let progress_bar_filename = final_path
378        .file_name()
379        .unwrap_or_default()
380        .to_string_lossy()
381        .into_owned();
382    let progress = create_progress_bar(
383        quiet_mode,
384        progress_bar_filename,
385        response_content_length,
386        false,
387    );
388
389    let mut source = response.take(response_content_length.unwrap_or(u64::MAX));
390    let mut buffered_reader = progress.wrap_read(&mut source);
391
392    // Stream data instead of reading all into memory
393    let mut buffer = [0u8; 8192];
394    let mut downloaded: u64 = 0;
395    let started_at = Instant::now();
396    loop {
397        let n = buffered_reader.read(&mut buffer)?;
398        if n == 0 {
399            break;
400        }
401        dest.write_all(&buffer[..n])?;
402        downloaded += n as u64;
403
404        if let Some(total) = response_content_length {
405            if let Some(cb) = status_callback {
406                let percent = downloaded as f64 / total.max(1) as f64 * 100.0;
407                cb(format!(
408                    "PROGRESS: {:.1}% ({}/{})",
409                    percent, downloaded, total
410                ));
411            }
412        }
413
414        throttle_download(downloaded, started_at, optimizer.speed_limit);
415    }
416
417    progress.finish_with_message("Download completed\n");
418
419    if is_iso && options.verify_iso {
420        verify_file_sha256(
421            &final_path,
422            options.expected_sha256.as_deref(),
423            status_callback,
424        )?;
425    } else if let Some(expected) = options.expected_sha256.as_deref() {
426        verify_file_sha256(&final_path, Some(expected), status_callback)?;
427    }
428
429    Ok(())
430}
431
432/// Parse the `filename` or `filename*` from a `Content-Disposition` header value.
433///
434/// Prefers the RFC 5987 `filename*` form (percent-encoded, with charset) over
435/// the plain `filename` form.  Returns `None` if neither is present or valid.
436pub fn parse_content_disposition_filename(header_value: &str) -> Option<String> {
437    let mut plain: Option<String> = None;
438
439    for part in header_value.split(';') {
440        let part = part.trim();
441
442        // filename*= (RFC 5987) takes priority
443        if let Some(val) = part.strip_prefix("filename*=") {
444            let val = val.trim().trim_matches('"');
445            // Format: charset'language'encoded-value
446            let mut parts = val.splitn(3, '\'');
447            let _charset = parts.next();
448            let _language = parts.next();
449            if let Some(encoded) = parts.next() {
450                if let Ok(decoded) = urlencoding::decode(encoded) {
451                    let name = decoded.into_owned();
452                    if !name.is_empty() {
453                        return Some(name);
454                    }
455                }
456            }
457        }
458
459        // Plain filename= (fallback)
460        if plain.is_none() {
461            if let Some(val) = part.strip_prefix("filename=") {
462                let name = val.trim().trim_matches('"').to_string();
463                if !name.is_empty() {
464                    plain = Some(name);
465                }
466            }
467        }
468    }
469
470    plain
471}
472
473fn throttle_download(downloaded: u64, started_at: Instant, speed_limit: Option<u64>) {
474    let Some(limit) = speed_limit else { return };
475    if limit == 0 {
476        return;
477    }
478
479    let expected_elapsed = Duration::from_secs_f64(downloaded as f64 / limit as f64);
480    let actual_elapsed = started_at.elapsed();
481    if expected_elapsed > actual_elapsed {
482        std::thread::sleep(expected_elapsed - actual_elapsed);
483    }
484}
485
486/// Verify the integrity of an ISO file by calculating its SHA-256 hash.
487///
488/// After download, this function calculates the SHA-256 checksum of the file
489/// and displays it for manual comparison with the source.
490///
491/// # Arguments
492///
493/// * `path` - Path to the ISO file
494/// * `callback` - Optional callback for status messages
495///
496/// # Example
497///
498/// ```rust,no_run
499/// use kget::verify_iso_integrity;
500/// use std::path::Path;
501///
502/// verify_iso_integrity(
503///     Path::new("ubuntu-22.04-desktop-amd64.iso"),
504///     Some(&|msg| println!("Status: {}", msg)),
505/// ).unwrap();
506/// ```
507///
508/// # Output
509///
510/// Prints the SHA256 hash to stdout and sends it via callback if provided.
511pub fn verify_iso_integrity(
512    path: &Path,
513    callback: Option<&(dyn Fn(String) + Send + Sync)>,
514) -> Result<(), Box<dyn Error + Send + Sync>> {
515    verify_file_sha256(path, None, callback).map(|_| ())
516}
517
518/// Calculate a file SHA-256 hash and optionally compare it with an expected value.
519pub fn verify_file_sha256(
520    path: &Path,
521    expected_hash: Option<&str>,
522    callback: Option<&(dyn Fn(String) + Send + Sync)>,
523) -> Result<String, Box<dyn Error + Send + Sync>> {
524    let send = |msg: &str| {
525        if let Some(cb) = callback {
526            cb(msg.to_string());
527        }
528    };
529
530    send("Calculating SHA256 hash... (this may take a while for large ISOs)");
531
532    let mut file = File::open(path)?;
533    let mut hasher = sha2::Sha256::new();
534    let mut buffer = [0; 8192];
535    loop {
536        let n = file.read(&mut buffer)?;
537        if n == 0 {
538            break;
539        }
540        hasher.update(&buffer[..n]);
541    }
542    let hash = hex::encode(hasher.finalize());
543
544    send("Integrity check finished.");
545    send(&format!("SHA256: {}", hash));
546
547    if let Some(expected_hash) = expected_hash {
548        let expected_hash = expected_hash.trim().to_ascii_lowercase();
549        if hash != expected_hash {
550            return Err(
551                format!("SHA256 mismatch: expected {}, got {}", expected_hash, hash).into(),
552            );
553        }
554        send("SHA256 matches expected hash.");
555    }
556
557    Ok(hash)
558}