Skip to main content

gosh_dl/http/
mod.rs

1//! HTTP Download Engine
2//!
3//! This module handles HTTP/HTTPS downloads with support for:
4//! - Single and multi-connection (segmented) downloads
5//! - Resume capability via Range headers
6//! - Progress tracking
7//! - Custom headers, user-agent, referer
8//! - Connection pooling with rate limiting
9//! - Retry logic with exponential backoff
10//! - Checksum verification (MD5/SHA256)
11
12pub(crate) mod checksum;
13pub(crate) mod connection;
14#[cfg(feature = "recursive-http")]
15pub(crate) mod crawl;
16pub(crate) mod mirror;
17pub(crate) mod resume;
18pub(crate) mod segment;
19
20pub use checksum::{compute_checksum, verify_checksum, ChecksumAlgorithm, ExpectedChecksum};
21pub use connection::{ConnectionPool, RetryPolicy, SpeedCalculator};
22pub use mirror::MirrorManager;
23pub use resume::{check_resume, ResumeInfo};
24pub use segment::{calculate_segment_count, probe_server, SegmentedDownload, ServerCapabilities};
25
26use crate::config::EngineConfig;
27use crate::error::{EngineError, NetworkErrorKind, ProtocolErrorKind, Result, StorageErrorKind};
28use crate::storage::Segment;
29use crate::types::DownloadProgress;
30
31use futures::StreamExt;
32use parking_lot::RwLock;
33use reqwest::{Client, Response};
34use std::path::{Path, PathBuf};
35use std::sync::atomic::{AtomicU64, Ordering};
36use std::sync::Arc;
37use std::time::{Duration, Instant};
38use tokio::fs::{File, OpenOptions};
39use tokio::io::AsyncWriteExt;
40use tokio_util::sync::CancellationToken;
41
42pub(crate) const ACCEPT_ENCODING_IDENTITY: &str = "identity";
43
44fn log_progress_invariant(context: &str, progress: &DownloadProgress) {
45    if let Some(total_size) = progress.total_size {
46        if progress.completed_size > total_size {
47            debug_assert!(
48                progress.completed_size <= total_size,
49                "{} progress exceeded total size: {} > {}",
50                context,
51                progress.completed_size,
52                total_size
53            );
54            tracing::warn!(
55                "{} progress exceeded total size: {} > {}",
56                context,
57                progress.completed_size,
58                total_size
59            );
60        }
61    }
62}
63
64fn partial_path_for(save_path: &Path) -> PathBuf {
65    save_path.with_extension(
66        save_path
67            .extension()
68            .map(|e| format!("{}.part", e.to_string_lossy()))
69            .unwrap_or_else(|| "part".to_string()),
70    )
71}
72
73/// HTTP Downloader
74pub struct HttpDownloader {
75    pool: Arc<ConnectionPool>,
76    config: HttpDownloaderConfig,
77    retry_policy: RetryPolicy,
78}
79
80/// Configuration for HTTP downloader
81#[derive(Debug, Clone)]
82pub struct HttpDownloaderConfig {
83    pub connect_timeout: Duration,
84    pub read_timeout: Duration,
85    pub max_redirects: usize,
86    pub default_user_agent: String,
87}
88
89impl HttpDownloader {
90    /// Create a new HTTP downloader
91    pub fn new(config: &EngineConfig) -> Result<Self> {
92        // Create connection pool with rate limiting if configured
93        let pool = ConnectionPool::with_limits(
94            &config.http,
95            config.global_download_limit,
96            config.global_upload_limit,
97        )?;
98
99        // Create retry policy from config
100        let retry_policy = RetryPolicy::new(
101            config.http.max_retries as u32,
102            config.http.retry_delay_ms,
103            config.http.max_retry_delay_ms,
104        );
105
106        Ok(Self {
107            pool: Arc::new(pool),
108            config: HttpDownloaderConfig {
109                connect_timeout: Duration::from_secs(config.http.connect_timeout),
110                read_timeout: Duration::from_secs(config.http.read_timeout),
111                max_redirects: config.http.max_redirects,
112                default_user_agent: config.user_agent.clone(),
113            },
114            retry_policy,
115        })
116    }
117
118    /// Get the underlying client
119    fn client(&self) -> &Client {
120        self.pool.client()
121    }
122
123    /// Get the retry policy
124    pub fn retry_policy(&self) -> &RetryPolicy {
125        &self.retry_policy
126    }
127
128    /// Update the live HTTP bandwidth limits used by future requests.
129    pub fn set_bandwidth_limits(&self, download_limit: Option<u64>, upload_limit: Option<u64>) {
130        self.pool.set_download_limit(download_limit);
131        self.pool.set_upload_limit(upload_limit);
132    }
133
134    /// Download a file from a URL
135    ///
136    /// Returns the final path of the downloaded file
137    #[allow(clippy::too_many_arguments)]
138    pub async fn download<F>(
139        &self,
140        url: &str,
141        save_dir: &Path,
142        filename: Option<&str>,
143        user_agent: Option<&str>,
144        referer: Option<&str>,
145        headers: &[(String, String)],
146        cookies: Option<&[String]>,
147        checksum: Option<&ExpectedChecksum>,
148        cancel_token: CancellationToken,
149        progress_callback: F,
150    ) -> Result<PathBuf>
151    where
152        F: Fn(DownloadProgress) + Send + Sync + 'static,
153    {
154        self.download_with_scope(
155            url,
156            save_dir,
157            filename,
158            user_agent,
159            referer,
160            headers,
161            cookies,
162            checksum,
163            #[cfg(feature = "recursive-http")]
164            None,
165            cancel_token,
166            progress_callback,
167        )
168        .await
169    }
170
171    #[allow(clippy::too_many_arguments)]
172    async fn download_with_scope<F>(
173        &self,
174        url: &str,
175        save_dir: &Path,
176        filename: Option<&str>,
177        user_agent: Option<&str>,
178        referer: Option<&str>,
179        headers: &[(String, String)],
180        cookies: Option<&[String]>,
181        checksum: Option<&ExpectedChecksum>,
182        #[cfg(feature = "recursive-http")] redirect_scope: Option<
183            crate::http::crawl::RedirectScope,
184        >,
185        cancel_token: CancellationToken,
186        progress_callback: F,
187    ) -> Result<PathBuf>
188    where
189        F: Fn(DownloadProgress) + Send + Sync + 'static,
190    {
191        let progress_callback = Arc::new(progress_callback);
192        // Build the request
193        let mut request = self.client().get(url);
194
195        // Set user agent
196        let ua = user_agent.unwrap_or(&self.config.default_user_agent);
197        request = request.header("User-Agent", ua);
198
199        // Set referer if provided
200        if let Some(ref_url) = referer {
201            request = request.header("Referer", ref_url);
202        }
203
204        // Add custom headers
205        for (name, value) in headers {
206            request = request.header(name.as_str(), value.as_str());
207        }
208        request = request.header("Accept-Encoding", ACCEPT_ENCODING_IDENTITY);
209
210        // Add cookies if provided
211        if let Some(cookie_list) = cookies {
212            if !cookie_list.is_empty() {
213                let cookie_value = cookie_list.join("; ");
214                request = request.header("Cookie", cookie_value);
215            }
216        }
217
218        // Send HEAD request first to get metadata
219        let mut head_request = self.client().head(url).header("User-Agent", ua);
220        if let Some(cookie_list) = cookies {
221            if !cookie_list.is_empty() {
222                head_request = head_request.header("Cookie", cookie_list.join("; "));
223            }
224        }
225        head_request = head_request.header("Accept-Encoding", ACCEPT_ENCODING_IDENTITY);
226        let head_response = head_request.send().await;
227
228        let (content_length, supports_range, suggested_filename, etag, last_modified) =
229            match head_response {
230                Ok(resp) => {
231                    #[cfg(feature = "recursive-http")]
232                    if let Some(scope) = redirect_scope.as_ref() {
233                        crate::http::crawl::validate_redirect_scope(resp.url(), scope)?;
234                    }
235                    let length = resp
236                        .headers()
237                        .get("content-length")
238                        .and_then(|v| v.to_str().ok())
239                        .and_then(|s| s.parse::<u64>().ok());
240
241                    let supports_range = resp
242                        .headers()
243                        .get("accept-ranges")
244                        .and_then(|v| v.to_str().ok())
245                        .map(|v| v.contains("bytes"))
246                        .unwrap_or(false);
247
248                    // Try to get filename from Content-Disposition
249                    let suggested = resp
250                        .headers()
251                        .get("content-disposition")
252                        .and_then(|v| v.to_str().ok())
253                        .and_then(parse_content_disposition);
254
255                    let etag = resp
256                        .headers()
257                        .get("etag")
258                        .and_then(|v| v.to_str().ok())
259                        .map(|s| s.to_string());
260
261                    let last_modified = resp
262                        .headers()
263                        .get("last-modified")
264                        .and_then(|v| v.to_str().ok())
265                        .map(|s| s.to_string());
266
267                    (length, supports_range, suggested, etag, last_modified)
268                }
269                Err(_) => {
270                    // HEAD failed, we'll get metadata from GET response
271                    (None, false, None, None, None)
272                }
273            };
274
275        // Check for cancellation
276        if cancel_token.is_cancelled() {
277            return Err(EngineError::Shutdown);
278        }
279
280        // Determine filename
281        let final_filename = filename
282            .map(|s| s.to_string())
283            .or(suggested_filename)
284            .or_else(|| extract_filename_from_url(url))
285            .unwrap_or_else(|| "download".to_string());
286
287        // Ensure save directory exists
288        if !save_dir.exists() {
289            tokio::fs::create_dir_all(save_dir).await.map_err(|e| {
290                EngineError::storage(
291                    StorageErrorKind::Io,
292                    save_dir,
293                    format!("Failed to create directory: {}", e),
294                )
295            })?;
296        }
297
298        // Validate filename for path traversal attacks (security)
299        // Check each path component to prevent directory traversal
300        use std::path::Component;
301        for component in Path::new(&final_filename).components() {
302            match component {
303                Component::ParentDir => {
304                    return Err(EngineError::storage(
305                        StorageErrorKind::PathTraversal,
306                        Path::new(&final_filename),
307                        "Invalid filename: contains parent directory reference (..)",
308                    ));
309                }
310                Component::RootDir | Component::Prefix(_) => {
311                    return Err(EngineError::storage(
312                        StorageErrorKind::PathTraversal,
313                        Path::new(&final_filename),
314                        "Invalid filename: contains absolute path",
315                    ));
316                }
317                _ => {}
318            }
319        }
320
321        let save_path = save_dir.join(&final_filename);
322
323        // Use .part extension during download
324        let part_path = partial_path_for(&save_path);
325
326        // Check if we can resume
327        let existing_size = if supports_range && part_path.exists() {
328            tokio::fs::metadata(&part_path)
329                .await
330                .map(|m| m.len())
331                .unwrap_or(0)
332        } else {
333            0
334        };
335
336        let mut allow_resume = existing_size > 0;
337        let mut stream_attempt = 0u32;
338
339        loop {
340            let resume_from = if allow_resume { existing_size } else { 0 };
341            let if_range = if resume_from > 0 {
342                etag.as_deref().or(last_modified.as_deref())
343            } else {
344                None
345            };
346
347            let mut attempt_request = request.try_clone().ok_or_else(|| {
348                EngineError::Internal(
349                    "Failed to clone HTTP request builder for restartless retry".to_string(),
350                )
351            })?;
352
353            if resume_from > 0 {
354                attempt_request =
355                    attempt_request.header("Range", format!("bytes={}-", resume_from));
356                if let Some(if_range_val) = if_range {
357                    attempt_request = attempt_request.header("If-Range", if_range_val);
358                }
359            }
360
361            // Send the request
362            let response = attempt_request.send().await?;
363            #[cfg(feature = "recursive-http")]
364            if let Some(scope) = redirect_scope.as_ref() {
365                crate::http::crawl::validate_redirect_scope(response.url(), scope)?;
366            }
367
368            // Check response status
369            let status = response.status();
370            if !status.is_success() && status != reqwest::StatusCode::PARTIAL_CONTENT {
371                return Err(EngineError::network(
372                    NetworkErrorKind::HttpStatus(status.as_u16()),
373                    format!("HTTP error: {}", status),
374                ));
375            }
376
377            if resume_from > 0 {
378                let range_validation = resume::validate_ranged_response(
379                    resume_from,
380                    None,
381                    status,
382                    response
383                        .headers()
384                        .get("content-range")
385                        .and_then(|v| v.to_str().ok()),
386                    resume::RangedResponseContext {
387                        sent_if_range: if_range.is_some(),
388                        expected_etag: etag.as_deref(),
389                        expected_last_modified: last_modified.as_deref(),
390                        response_etag: response.headers().get("etag").and_then(|v| v.to_str().ok()),
391                        response_last_modified: response
392                            .headers()
393                            .get("last-modified")
394                            .and_then(|v| v.to_str().ok()),
395                    },
396                );
397
398                if let Err(err) = range_validation {
399                    if resume::should_restart_without_ranges(&err) {
400                        tracing::warn!(
401                            "HTTP resume for {} cannot continue safely ({}). Restarting from byte 0 with a single stream.",
402                            url,
403                            err
404                        );
405                        allow_resume = false;
406                        continue;
407                    }
408                    return Err(err);
409                }
410            }
411
412            // Get actual content length from response if not from HEAD
413            let response_content_length = response
414                .headers()
415                .get("content-length")
416                .and_then(|v| v.to_str().ok())
417                .and_then(|s| s.parse::<u64>().ok())
418                .map(|len| len + resume_from);
419
420            if let (Some(head_len), Some(get_len)) = (content_length, response_content_length) {
421                if head_len != get_len {
422                    tracing::warn!(
423                        "HEAD content-length mismatch for {}: HEAD={}, GET={}",
424                        url,
425                        head_len,
426                        get_len
427                    );
428                }
429            }
430
431            // Prefer the GET response length over HEAD when available.
432            let total_size = response_content_length.or(content_length);
433
434            // Open file for writing
435            let file = if resume_from > 0 && status == reqwest::StatusCode::PARTIAL_CONTENT {
436                // Append mode for resume
437                OpenOptions::new()
438                    .write(true)
439                    .append(true)
440                    .open(&part_path)
441                    .await
442                    .map_err(|e| {
443                        EngineError::storage(
444                            StorageErrorKind::Io,
445                            &part_path,
446                            format!("Failed to open file for append: {}", e),
447                        )
448                    })?
449            } else {
450                // Create a fresh partial file, truncating any stale resume data.
451                File::create(&part_path).await.map_err(|e| {
452                    EngineError::storage(
453                        StorageErrorKind::Io,
454                        &part_path,
455                        format!("Failed to create file: {}", e),
456                    )
457                })?
458            };
459
460            // Download with progress tracking
461            let downloaded = Arc::new(AtomicU64::new(resume_from));
462
463            // Stream the response body
464            let result = self
465                .stream_to_file(
466                    response,
467                    file,
468                    downloaded.clone(),
469                    total_size,
470                    cancel_token.clone(),
471                    {
472                        let progress_callback = Arc::clone(&progress_callback);
473                        move |completed, speed| {
474                            let progress = DownloadProgress {
475                                total_size,
476                                completed_size: completed,
477                                download_speed: speed,
478                                upload_speed: 0,
479                                connections: 1,
480                                seeders: 0,
481                                peers: 0,
482                                eta_seconds: total_size.and_then(|total| {
483                                    total.saturating_sub(completed).checked_div(speed)
484                                }),
485                            };
486                            log_progress_invariant("http download", &progress);
487                            progress_callback(progress);
488                        }
489                    },
490                )
491                .await;
492
493            match result {
494                Ok(_) => {
495                    // Verify checksum before renaming (if checksum was provided)
496                    if let Some(expected) = checksum {
497                        let verified = verify_checksum(&part_path, expected).await?;
498                        if !verified {
499                            let actual = compute_checksum(&part_path, expected.algorithm).await?;
500                            return Err(checksum::checksum_mismatch_error(
501                                &expected.value,
502                                &actual,
503                            ));
504                        }
505                        tracing::debug!(
506                            "Checksum verified: {} matches expected",
507                            expected.algorithm
508                        );
509                    }
510
511                    // Rename .part file to final name
512                    tokio::fs::rename(&part_path, &save_path)
513                        .await
514                        .map_err(|e| {
515                            EngineError::storage(
516                                StorageErrorKind::Io,
517                                &save_path,
518                                format!("Failed to rename file: {}", e),
519                            )
520                        })?;
521
522                    return Ok(save_path);
523                }
524                Err(e) => {
525                    // Keep .part file for potential resume
526                    if e.is_retryable() && self.retry_policy.should_retry(stream_attempt, &e) {
527                        stream_attempt += 1;
528                        let delay = self.retry_policy.delay_for_attempt(stream_attempt - 1);
529                        if supports_range {
530                            tracing::warn!(
531                                "Stream error for {} (attempt {}/{}), will resume from partial: {}",
532                                url,
533                                stream_attempt,
534                                self.retry_policy.max_attempts,
535                                e
536                            );
537                            // Re-enter the loop: it will detect the .part file
538                            // and send a Range request to resume from where we left off
539                            allow_resume = true;
540                        } else {
541                            tracing::warn!(
542                                "Stream error for {} (attempt {}/{}), restarting (no range support): {}",
543                                url,
544                                stream_attempt,
545                                self.retry_policy.max_attempts,
546                                e
547                            );
548                            // Server doesn't support ranges — must restart from byte 0
549                            allow_resume = false;
550                        }
551                        tokio::time::sleep(delay).await;
552                        continue;
553                    }
554                    return Err(e);
555                }
556            }
557        }
558    }
559
560    /// Stream response body to file with progress tracking
561    async fn stream_to_file<F>(
562        &self,
563        response: Response,
564        mut file: File,
565        downloaded: Arc<AtomicU64>,
566        total_size: Option<u64>,
567        cancel_token: CancellationToken,
568        progress_callback: F,
569    ) -> Result<()>
570    where
571        F: Fn(u64, u64) + Send,
572    {
573        let mut stream = response.bytes_stream();
574        let mut last_update = Instant::now();
575        let mut bytes_since_update: u64 = 0;
576        let update_interval = Duration::from_millis(250); // Update progress 4 times per second
577
578        while let Some(chunk_result) = tokio::select! {
579            chunk = stream.next() => chunk,
580            _ = cancel_token.cancelled() => {
581                file.flush().await.ok();
582                return Err(EngineError::Shutdown);
583            }
584        } {
585            let chunk: bytes::Bytes = chunk_result.map_err(EngineError::from)?;
586
587            let chunk_len = chunk.len() as u64;
588
589            // Apply rate limiting if configured
590            self.pool.acquire_download(chunk_len).await;
591
592            // Write chunk to file
593            file.write_all(&chunk).await.map_err(|e| {
594                EngineError::storage(
595                    StorageErrorKind::Io,
596                    PathBuf::new(),
597                    format!("Failed to write: {}", e),
598                )
599            })?;
600
601            // Record downloaded bytes for stats
602            self.pool.record_download(chunk_len);
603
604            // Update counters
605            let new_total = downloaded.fetch_add(chunk_len, Ordering::Relaxed) + chunk_len;
606            if let Some(expected) = total_size {
607                if new_total > expected {
608                    return Err(EngineError::protocol(
609                        ProtocolErrorKind::InvalidResponse,
610                        format!(
611                            "Response exceeded expected size: received {} bytes, expected {} bytes",
612                            new_total, expected
613                        ),
614                    ));
615                }
616            }
617            bytes_since_update += chunk_len;
618
619            // Emit progress at intervals
620            let now = Instant::now();
621            if now.duration_since(last_update) >= update_interval {
622                let elapsed_secs = now.duration_since(last_update).as_secs_f64();
623                let speed = if elapsed_secs > 0.0 {
624                    (bytes_since_update as f64 / elapsed_secs) as u64
625                } else {
626                    0
627                };
628
629                progress_callback(new_total, speed);
630
631                last_update = now;
632                bytes_since_update = 0;
633            }
634        }
635
636        // Flush and sync
637        file.flush().await.map_err(|e| {
638            EngineError::storage(
639                StorageErrorKind::Io,
640                PathBuf::new(),
641                format!("Failed to flush: {}", e),
642            )
643        })?;
644
645        file.sync_all().await.map_err(|e| {
646            EngineError::storage(
647                StorageErrorKind::Io,
648                PathBuf::new(),
649                format!("Failed to sync: {}", e),
650            )
651        })?;
652
653        // Final progress update
654        let final_size = downloaded.load(Ordering::Relaxed);
655        progress_callback(final_size, 0);
656
657        // Validate received size matches expected (if known)
658        if let Some(expected) = total_size {
659            if final_size != expected {
660                return Err(EngineError::protocol(
661                    ProtocolErrorKind::InvalidResponse,
662                    format!(
663                        "Download size mismatch: received {} bytes, expected {} bytes",
664                        final_size, expected
665                    ),
666                ));
667            }
668        }
669
670        Ok(())
671    }
672
673    /// Download a file using multiple connections (segmented download)
674    ///
675    /// This method probes the server first and uses segmented downloads
676    /// if the server supports Range requests and the file is large enough.
677    #[allow(clippy::too_many_arguments)]
678    /// Download with segmented multi-connection support.
679    ///
680    /// Returns the final path and optionally an Arc reference to the SegmentedDownload
681    /// (only when using segmented download mode).
682    pub async fn download_segmented<F>(
683        &self,
684        url: &str,
685        save_dir: &Path,
686        filename: Option<&str>,
687        user_agent: Option<&str>,
688        referer: Option<&str>,
689        headers: &[(String, String)],
690        cookies: Option<&[String]>,
691        checksum: Option<&ExpectedChecksum>,
692        max_connections: usize,
693        min_segment_size: u64,
694        cancel_token: CancellationToken,
695        saved_segments: Option<Vec<Segment>>,
696        progress_callback: F,
697        segmented_ref: Option<Arc<RwLock<Option<Arc<SegmentedDownload>>>>>,
698    ) -> Result<(PathBuf, Option<Arc<SegmentedDownload>>)>
699    where
700        F: Fn(DownloadProgress) + Send + Sync + 'static,
701    {
702        self.download_segmented_with_scope(
703            url,
704            save_dir,
705            filename,
706            user_agent,
707            referer,
708            headers,
709            cookies,
710            checksum,
711            #[cfg(feature = "recursive-http")]
712            None,
713            max_connections,
714            min_segment_size,
715            cancel_token,
716            saved_segments,
717            progress_callback,
718            segmented_ref,
719        )
720        .await
721    }
722
723    #[allow(clippy::too_many_arguments)]
724    pub(crate) async fn download_segmented_with_scope<F>(
725        &self,
726        url: &str,
727        save_dir: &Path,
728        filename: Option<&str>,
729        user_agent: Option<&str>,
730        referer: Option<&str>,
731        headers: &[(String, String)],
732        cookies: Option<&[String]>,
733        checksum: Option<&ExpectedChecksum>,
734        #[cfg(feature = "recursive-http")] redirect_scope: Option<
735            crate::http::crawl::RedirectScope,
736        >,
737        max_connections: usize,
738        min_segment_size: u64,
739        cancel_token: CancellationToken,
740        saved_segments: Option<Vec<Segment>>,
741        progress_callback: F,
742        segmented_ref: Option<Arc<RwLock<Option<Arc<SegmentedDownload>>>>>,
743    ) -> Result<(PathBuf, Option<Arc<SegmentedDownload>>)>
744    where
745        F: Fn(DownloadProgress) + Send + Sync + 'static,
746    {
747        let progress_callback = Arc::new(progress_callback);
748        let ua = user_agent.unwrap_or(&self.config.default_user_agent);
749
750        // Probe server capabilities
751        let capabilities = probe_server(self.client(), url, ua).await?;
752
753        // Determine filename
754        let final_filename = filename
755            .map(|s| s.to_string())
756            .or(capabilities.suggested_filename.clone())
757            .or_else(|| extract_filename_from_url(url))
758            .unwrap_or_else(|| "download".to_string());
759
760        // Ensure save directory exists
761        if !save_dir.exists() {
762            tokio::fs::create_dir_all(save_dir).await.map_err(|e| {
763                EngineError::storage(
764                    StorageErrorKind::Io,
765                    save_dir,
766                    format!("Failed to create directory: {}", e),
767                )
768            })?;
769        }
770
771        let save_path = save_dir.join(&final_filename);
772
773        // Decide whether to use segmented download
774        let use_segmented = capabilities.supports_range
775            && capabilities
776                .content_length
777                .map(|l| l > min_segment_size)
778                .unwrap_or(false);
779
780        if use_segmented {
781            let total_size = capabilities.content_length.unwrap();
782
783            // Create segmented download
784            let mut download = SegmentedDownload::new(
785                url.to_string(),
786                total_size,
787                save_path.clone(),
788                true,
789                capabilities.etag,
790                capabilities.last_modified,
791            );
792
793            // Restore or initialize segments
794            if let Some(segments) = saved_segments {
795                tracing::debug!("Restoring {} saved segments", segments.len());
796                download.restore_segments(segments);
797            } else {
798                download.init_segments(max_connections, min_segment_size);
799            }
800
801            // Wrap in Arc for sharing
802            let download = Arc::new(download);
803            let download_ref = Arc::clone(&download);
804
805            // Populate shared reference for external access during download (for persistence)
806            if let Some(ref slot) = segmented_ref {
807                *slot.write() = Some(Arc::clone(&download));
808            }
809
810            // Build headers vec
811            let mut all_headers = headers.to_vec();
812            if let Some(r) = referer {
813                all_headers.push(("Referer".to_string(), r.to_string()));
814            }
815            // Add cookies to headers
816            if let Some(cookie_list) = cookies {
817                if !cookie_list.is_empty() {
818                    all_headers.push(("Cookie".to_string(), cookie_list.join("; ")));
819                }
820            }
821
822            // Start download
823            let segmented_result = download
824                .start_with_scope(
825                    self.client(),
826                    ua,
827                    &all_headers,
828                    max_connections,
829                    &self.retry_policy,
830                    #[cfg(feature = "recursive-http")]
831                    redirect_scope.clone(),
832                    cancel_token.clone(),
833                    {
834                        let progress_callback = Arc::clone(&progress_callback);
835                        move |progress| progress_callback(progress)
836                    },
837                )
838                .await;
839
840            if let Err(err) = segmented_result {
841                if resume::should_restart_without_ranges(&err) && !cancel_token.is_cancelled() {
842                    tracing::warn!(
843                        "Segmented download for {} cannot continue safely ({}). Restarting from byte 0 with a single stream.",
844                        url,
845                        err
846                    );
847                    if let Some(ref slot) = segmented_ref {
848                        *slot.write() = None;
849                    }
850                    resume::cleanup_partial(&partial_path_for(&save_path)).await?;
851                    let path = self
852                        .download_with_scope(
853                            url,
854                            save_dir,
855                            Some(&final_filename),
856                            user_agent,
857                            referer,
858                            headers,
859                            cookies,
860                            checksum,
861                            #[cfg(feature = "recursive-http")]
862                            redirect_scope,
863                            cancel_token,
864                            {
865                                let progress_callback = Arc::clone(&progress_callback);
866                                move |progress| progress_callback(progress)
867                            },
868                        )
869                        .await?;
870                    return Ok((path, None));
871                }
872                return Err(err);
873            }
874
875            // Verify checksum if provided
876            if let Some(expected) = checksum {
877                let verified = verify_checksum(&save_path, expected).await?;
878                if !verified {
879                    let actual = compute_checksum(&save_path, expected.algorithm).await?;
880                    return Err(checksum::checksum_mismatch_error(&expected.value, &actual));
881                }
882                tracing::debug!("Checksum verified: {} matches expected", expected.algorithm);
883            }
884
885            Ok((save_path, Some(download_ref)))
886        } else {
887            // Fall back to single-connection download
888            let path = self
889                .download_with_scope(
890                    url,
891                    save_dir,
892                    Some(&final_filename),
893                    user_agent,
894                    referer,
895                    headers,
896                    cookies,
897                    checksum,
898                    #[cfg(feature = "recursive-http")]
899                    redirect_scope,
900                    cancel_token,
901                    {
902                        let progress_callback = Arc::clone(&progress_callback);
903                        move |progress| progress_callback(progress)
904                    },
905                )
906                .await?;
907            Ok((path, None))
908        }
909    }
910}
911
912/// Parse filename from Content-Disposition header
913pub fn parse_content_disposition(header: &str) -> Option<String> {
914    // Look for filename="..." or filename*=UTF-8''...
915    if let Some(start) = header.find("filename=") {
916        let rest = &header[start + 9..];
917        if let Some(stripped) = rest.strip_prefix('"') {
918            // Quoted filename
919            let end = stripped.find('"')?;
920            return Some(stripped[..end].to_string());
921        } else {
922            // Unquoted filename
923            let end = rest.find(';').unwrap_or(rest.len());
924            return Some(rest[..end].trim().to_string());
925        }
926    }
927
928    if let Some(start) = header.find("filename*=") {
929        let rest = &header[start + 10..];
930        // UTF-8'' prefix
931        if let Some(quote_start) = rest.find("''") {
932            let encoded = &rest[quote_start + 2..];
933            let end = encoded.find(';').unwrap_or(encoded.len());
934            // URL decode
935            if let Ok(decoded) = urlencoding::decode(&encoded[..end]) {
936                return Some(decoded.to_string());
937            }
938        }
939    }
940
941    None
942}
943
944/// Extract filename from URL path
945fn extract_filename_from_url(url: &str) -> Option<String> {
946    url::Url::parse(url)
947        .ok()?
948        .path_segments()?
949        .next_back()
950        .filter(|s| !s.is_empty())
951        .map(|s| {
952            // URL decode the filename
953            urlencoding::decode(s)
954                .map(|d| d.to_string())
955                .unwrap_or_else(|_| s.to_string())
956        })
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962
963    #[test]
964    fn test_parse_content_disposition() {
965        assert_eq!(
966            parse_content_disposition("attachment; filename=\"test.zip\""),
967            Some("test.zip".to_string())
968        );
969
970        assert_eq!(
971            parse_content_disposition("attachment; filename=test.zip"),
972            Some("test.zip".to_string())
973        );
974    }
975
976    #[test]
977    fn test_extract_filename_from_url() {
978        assert_eq!(
979            extract_filename_from_url("https://example.com/path/to/file.zip"),
980            Some("file.zip".to_string())
981        );
982
983        assert_eq!(
984            extract_filename_from_url("https://example.com/path/to/file%20name.zip"),
985            Some("file name.zip".to_string())
986        );
987    }
988}