1pub(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
73pub struct HttpDownloader {
75 pool: Arc<ConnectionPool>,
76 config: HttpDownloaderConfig,
77 retry_policy: RetryPolicy,
78}
79
80#[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 pub fn new(config: &EngineConfig) -> Result<Self> {
92 let pool = ConnectionPool::with_limits(
94 &config.http,
95 config.global_download_limit,
96 config.global_upload_limit,
97 )?;
98
99 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 fn client(&self) -> &Client {
120 self.pool.client()
121 }
122
123 pub fn retry_policy(&self) -> &RetryPolicy {
125 &self.retry_policy
126 }
127
128 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 #[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 let mut request = self.client().get(url);
194
195 let ua = user_agent.unwrap_or(&self.config.default_user_agent);
197 request = request.header("User-Agent", ua);
198
199 if let Some(ref_url) = referer {
201 request = request.header("Referer", ref_url);
202 }
203
204 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 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 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 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 (None, false, None, None, None)
272 }
273 };
274
275 if cancel_token.is_cancelled() {
277 return Err(EngineError::Shutdown);
278 }
279
280 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 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 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 let part_path = partial_path_for(&save_path);
325
326 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 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 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 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 let total_size = response_content_length.or(content_length);
433
434 let file = if resume_from > 0 && status == reqwest::StatusCode::PARTIAL_CONTENT {
436 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 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 let downloaded = Arc::new(AtomicU64::new(resume_from));
462
463 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 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 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 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 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 allow_resume = false;
550 }
551 tokio::time::sleep(delay).await;
552 continue;
553 }
554 return Err(e);
555 }
556 }
557 }
558 }
559
560 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); 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 self.pool.acquire_download(chunk_len).await;
591
592 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 self.pool.record_download(chunk_len);
603
604 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 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 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 let final_size = downloaded.load(Ordering::Relaxed);
655 progress_callback(final_size, 0);
656
657 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 #[allow(clippy::too_many_arguments)]
678 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 let capabilities = probe_server(self.client(), url, ua).await?;
752
753 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 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 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 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 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 let download = Arc::new(download);
803 let download_ref = Arc::clone(&download);
804
805 if let Some(ref slot) = segmented_ref {
807 *slot.write() = Some(Arc::clone(&download));
808 }
809
810 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 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 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 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 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
912pub fn parse_content_disposition(header: &str) -> Option<String> {
914 if let Some(start) = header.find("filename=") {
916 let rest = &header[start + 9..];
917 if let Some(stripped) = rest.strip_prefix('"') {
918 let end = stripped.find('"')?;
920 return Some(stripped[..end].to_string());
921 } else {
922 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 if let Some(quote_start) = rest.find("''") {
932 let encoded = &rest[quote_start + 2..];
933 let end = encoded.find(';').unwrap_or(encoded.len());
934 if let Ok(decoded) = urlencoding::decode(&encoded[..end]) {
936 return Some(decoded.to_string());
937 }
938 }
939 }
940
941 None
942}
943
944fn 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 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}