1use async_trait::async_trait;
2use futures::StreamExt;
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5use tracing::{debug, info, warn};
6
7use crate::engine::active_output_registry::global_registry;
8use crate::engine::command::{Command, CommandStatus};
9use crate::error::{Aria2Error, FatalError, RecoverableError, Result};
10use crate::filesystem::disk_writer::{DefaultDiskWriter, DiskWriter};
11use crate::rate_limiter::{RateLimiter, RateLimiterConfig, ThrottledWriter};
12use crate::request::request_group::{DownloadOptions, GroupId, RequestGroup};
13use aria2_protocol::metalink::parser::UrlEntry;
14
15pub struct MetalinkDownloadCommand {
16 group: Arc<tokio::sync::RwLock<RequestGroup>>,
17 client: reqwest::Client,
18 output_path: std::path::PathBuf,
19 started: bool,
20 completed: bool,
21 completed_bytes: u64,
22 metalink_data: Vec<u8>,
23}
24
25impl MetalinkDownloadCommand {
26 pub fn new(
27 gid: GroupId,
28 metalink_bytes: &[u8],
29 options: &DownloadOptions,
30 output_dir: Option<&str>,
31 ) -> Result<Self> {
32 let doc = aria2_protocol::metalink::parser::MetalinkDocument::parse(metalink_bytes)
33 .map_err(|e| {
34 Aria2Error::Fatal(FatalError::Config(format!("Metalink parse failed: {}", e)))
35 })?;
36
37 let file = doc.single_file().ok_or_else(|| {
38 Aria2Error::Fatal(FatalError::Config(
39 "Metalink contains multiple files or no files".into(),
40 ))
41 })?;
42
43 if file.urls.is_empty() {
44 return Err(Aria2Error::Fatal(FatalError::Config(
45 "Metalink file has no download URLs".into(),
46 )));
47 }
48
49 let dir = output_dir
50 .map(|d| d.to_string())
51 .or_else(|| options.dir.clone())
52 .unwrap_or_else(|| ".".to_string());
53
54 let filename = file.name.clone();
55 let path = std::path::PathBuf::from(&dir).join(&filename);
56
57 let urls: Vec<String> = file
58 .get_sorted_urls()
59 .iter()
60 .map(|u| u.url.clone())
61 .collect();
62 let group = RequestGroup::new(gid, urls, options.clone());
63
64 let client = reqwest::Client::builder()
65 .connect_timeout(Duration::from_secs(30))
66 .timeout(Duration::from_secs(300))
67 .user_agent("aria2-rust/0.1.0")
68 .redirect(reqwest::redirect::Policy::limited(5))
69 .build()
70 .map_err(|e| {
71 Aria2Error::Fatal(FatalError::Config(format!(
72 "HTTP client build failed: {}",
73 e
74 )))
75 })?;
76
77 info!(
78 "MetalinkDownloadCommand created: {} -> {} ({} mirrors)",
79 file.name,
80 path.display(),
81 file.urls.len()
82 );
83
84 Ok(Self {
85 group: Arc::new(tokio::sync::RwLock::new(group)),
86 client,
87 output_path: path,
88 started: false,
89 completed: false,
90 completed_bytes: 0,
91 metalink_data: metalink_bytes.to_vec(),
92 })
93 }
94
95 pub async fn group(&self) -> tokio::sync::RwLockReadGuard<'_, RequestGroup> {
96 self.group.read().await
97 }
98}
99
100#[async_trait]
101impl Command for MetalinkDownloadCommand {
102 async fn execute(&mut self) -> Result<()> {
103 if !self.started {
104 self.group.write().await.start().await?;
105 self.started = true;
106 }
107
108 let doc = aria2_protocol::metalink::parser::MetalinkDocument::parse(&self.metalink_data)
109 .map_err(|e| {
110 Aria2Error::Fatal(FatalError::Config(format!("Metalink parse error: {}", e)))
111 })?;
112
113 let file = doc.single_file().ok_or_else(|| {
114 Aria2Error::Fatal(FatalError::Config("No available file after parsing".into()))
115 })?;
116
117 let sorted_urls = file.get_sorted_urls();
118 if sorted_urls.is_empty() {
119 return Err(Aria2Error::Fatal(FatalError::Config(
120 "No download mirrors available".into(),
121 )));
122 }
123
124 if let Some(parent) = self.output_path.parent()
125 && !parent.exists()
126 {
127 std::fs::create_dir_all(parent).map_err(|e| {
128 Aria2Error::Fatal(FatalError::Config(format!("mkdir failed: {}", e)))
129 })?;
130 }
131
132 let resolved_output_path = global_registry().resolve(&self.output_path).await;
136
137 let release_path = |path: &std::path::Path| {
139 let p = path.to_path_buf();
140 #[allow(clippy::let_underscore_future)]
142 let _ = tokio::spawn(async move {
143 global_registry().release(&p).await;
144 });
145 };
146
147 let expected_size = file.size;
148 let hash_entry = file.hashes.first().cloned();
149
150 let mut last_error = None;
151
152 for url_entry in &sorted_urls {
153 debug!(
154 "Trying mirror [priority={}] : {}",
155 url_entry.priority, url_entry.url
156 );
157
158 match self.try_download_url(&url_entry.url, expected_size).await {
159 Ok(data) => {
160 if let Some(ref hash) = hash_entry
161 && !self.verify_hash(&data, hash)?
162 {
163 warn!(
164 "Hash verification failed [{}]: trying next mirror",
165 hash.algo.as_standard_name()
166 );
167 last_error = Some(Aria2Error::Recoverable(
168 RecoverableError::TemporaryNetworkFailure {
169 message: format!(
170 "Hash verification failed: {}",
171 hash.algo.as_standard_name()
172 ),
173 },
174 ));
175 continue;
176 }
177
178 let raw_writer = DefaultDiskWriter::new(&resolved_output_path);
179 let rate_limit = {
180 let g = self.group.read().await;
181 g.options().max_download_limit
182 };
183 let mut writer: Box<dyn DiskWriter> = match rate_limit {
184 Some(rate) if rate > 0 => Box::new(ThrottledWriter::new(
185 raw_writer,
186 RateLimiter::new(&RateLimiterConfig::new(Some(rate), None)),
187 )),
188 _ => Box::new(raw_writer),
189 };
190 writer.write(&data).await?;
191 writer.finalize().await.ok();
192
193 self.completed_bytes = data.len() as u64;
194
195 {
196 let mut g = self.group.write().await;
197 g.update_progress(self.completed_bytes).await;
198 g.update_speed(self.completed_bytes, 0).await;
199 g.complete().await?;
200 }
201
202 info!(
203 "Metalink download done: {} ({} bytes from {})",
204 resolved_output_path.display(),
205 self.completed_bytes,
206 url_entry.url
207 );
208 self.completed = true;
209 release_path(&resolved_output_path);
210 return Ok(());
211 }
212 Err(e) => {
213 warn!("Mirror download failed {}: {}", url_entry.url, e);
214 last_error = Some(e);
215 }
216 }
217 }
218
219 release_path(&resolved_output_path);
220 Err(last_error
221 .unwrap_or_else(|| Aria2Error::Fatal(FatalError::Config("All mirrors failed".into()))))
222 }
223
224 fn status(&self) -> CommandStatus {
225 if self.completed {
226 CommandStatus::Completed
227 } else if self.completed_bytes > 0 {
228 CommandStatus::Running
229 } else {
230 CommandStatus::Pending
231 }
232 }
233
234 fn timeout(&self) -> Option<Duration> {
235 Some(Duration::from_secs(600))
236 }
237}
238
239impl MetalinkDownloadCommand {
240 async fn try_download_url(&mut self, url: &str, expected_size: Option<u64>) -> Result<Vec<u8>> {
241 let response = self.client.get(url).send().await.map_err(|e| {
242 Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
243 message: format!("HTTP request failed: {}", e),
244 })
245 })?;
246
247 let status = response.status();
248 if !status.is_success() && status.as_u16() != 206 {
249 if status.as_u16() >= 500 {
250 return Err(Aria2Error::Recoverable(RecoverableError::ServerError {
251 code: status.as_u16(),
252 }));
253 }
254 return Err(Aria2Error::Fatal(FatalError::Config(format!(
255 "HTTP error: {}",
256 status
257 ))));
258 }
259
260 let total_length = response.content_length().unwrap_or(0) as u64;
261
262 {
263 let mut g = self.group.write().await;
264 g.set_total_length(total_length.max(expected_size.unwrap_or(0)))
265 .await;
266 }
267
268 let mut data = Vec::with_capacity(total_length as usize);
269 let mut stream = response.bytes_stream();
270 let _start_time = Instant::now();
271 let mut last_speed_update = Instant::now();
272 let mut last_completed = 0u64;
273
274 while let Some(chunk_result) = stream.next().await {
275 let bytes: bytes::Bytes = chunk_result.map_err(|e: reqwest::Error| {
276 Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
277 message: e.to_string(),
278 })
279 })?;
280 data.extend_from_slice(&bytes);
281 self.completed_bytes = data.len() as u64;
282
283 let elapsed = last_speed_update.elapsed();
284 if elapsed.as_millis() >= 500 {
285 let delta = self.completed_bytes - last_completed;
286 let speed = (delta as f64 / elapsed.as_secs_f64()) as u64;
287 let g = self.group.write().await;
288 g.update_progress(self.completed_bytes).await;
289 g.update_speed(speed, 0).await;
290 last_speed_update = Instant::now();
291 last_completed = self.completed_bytes;
292 }
293 }
294
295 Ok(data)
296 }
297
298 fn verify_hash(
299 &self,
300 data: &[u8],
301 hash: &aria2_protocol::metalink::parser::HashEntry,
302 ) -> Result<bool> {
303 use aria2_protocol::metalink::parser::HashAlgorithm;
304
305 match hash.algo {
306 HashAlgorithm::Md5 => {
307 let digest = md5::compute(data);
308 Ok(format!("{:x}", digest) == hash.value)
309 }
310 HashAlgorithm::Sha1 => {
311 use sha1::Digest;
312 let mut hasher = sha1::Sha1::new();
313 hasher.update(data);
314 let result = hasher.finalize();
315 Ok(format!("{:x}", result) == hash.value)
316 }
317 HashAlgorithm::Sha256 => {
318 use sha2::Digest;
319 let mut hasher = sha2::Sha256::new();
320 hasher.update(data);
321 let result = hasher.finalize();
322 Ok(format!("{:x}", result) == hash.value)
323 }
324 HashAlgorithm::Sha512 => {
325 use sha2::Digest;
326 let mut hasher = sha2::Sha512::new();
327 hasher.update(data);
328 let result = hasher.finalize();
329 Ok(format!("{:x}", result) == hash.value)
330 }
331 }
332 }
333}
334
335pub fn select_mirrors_by_priority<'a>(
372 resources: &'a [UrlEntry],
373 location_preference: &str,
374) -> Vec<&'a UrlEntry> {
375 let mut sorted: Vec<&'a UrlEntry> = resources.iter().collect();
376
377 sorted.sort_by(|a, b| {
378 let pri_cmp = b.priority.cmp(&a.priority);
380 if pri_cmp != std::cmp::Ordering::Equal {
381 return pri_cmp;
382 }
383
384 if !location_preference.is_empty() {
386 let a_matches = a
387 .location
388 .as_ref()
389 .map(|l| {
390 l.contains(location_preference) || location_preference.contains(l.as_str())
391 })
392 .unwrap_or(false);
393 let b_matches = b
394 .location
395 .as_ref()
396 .map(|l| {
397 l.contains(location_preference) || location_preference.contains(l.as_str())
398 })
399 .unwrap_or(false);
400
401 if a_matches != b_matches {
403 return b_matches.cmp(&a_matches);
404 }
405 }
406
407 std::cmp::Ordering::Equal
408 });
409
410 sorted
411}
412
413pub async fn try_mirrors_with_failover<F, Fut>(
455 sorted_urls: &[&UrlEntry],
456 download_fn: F,
457) -> std::result::Result<Vec<u8>, String>
458where
459 F: Fn(&str) -> Fut,
460 Fut: std::future::Future<Output = std::result::Result<Vec<u8>, String>>,
461{
462 for (i, url_res) in sorted_urls.iter().enumerate() {
463 info!(
464 index = i,
465 url = %url_res.url,
466 priority = url_res.priority,
467 "Trying mirror"
468 );
469
470 match download_fn(&url_res.url).await {
471 Ok(data) => {
472 info!(
473 index = i,
474 size = data.len(),
475 url = %url_res.url,
476 "Mirror succeeded"
477 );
478 return Ok(data);
479 }
480 Err(e) => {
481 warn!(
482 index = i,
483 url = %url_res.url,
484 error = %e,
485 "Mirror failed, trying next"
486 );
487 continue;
488 }
489 }
490 }
491
492 Err(format!("All {} mirrors failed", sorted_urls.len()))
493}
494
495#[cfg(test)]
500mod tests {
501 use super::*;
502 use aria2_protocol::metalink::parser::UrlEntry;
503
504 #[test]
509 fn test_priority_descending_order() {
510 let urls = vec![
511 UrlEntry::new("http://mirror3.example.com/file.bin").with_priority(1),
512 UrlEntry::new("http://mirror1.example.com/file.bin").with_priority(3),
513 UrlEntry::new("http://mirror2.example.com/file.bin").with_priority(2),
514 ];
515
516 let sorted = select_mirrors_by_priority(&urls, "");
517
518 assert_eq!(sorted.len(), 3, "Should return all URLs");
520 assert_eq!(
521 sorted[0].priority, 3,
522 "First URL should have highest priority (3)"
523 );
524 assert_eq!(
525 sorted[1].priority, 2,
526 "Second URL should have medium priority (2)"
527 );
528 assert_eq!(
529 sorted[2].priority, 1,
530 "Third URL should have lowest priority (1)"
531 );
532
533 assert!(
535 sorted[0].url.contains("mirror1"),
536 "First should be mirror1 (priority 3)"
537 );
538 assert!(
539 sorted[1].url.contains("mirror2"),
540 "Second should be mirror2 (priority 2)"
541 );
542 assert!(
543 sorted[2].url.contains("mirror3"),
544 "Third should be mirror3 (priority 1)"
545 );
546 }
547
548 #[test]
553 fn test_location_preference_boosts_matching() {
554 let urls = vec![
555 UrlEntry::new("http://us-mirror1.example.com/file.bin")
556 .with_priority(5)
557 .with_location("us"),
558 UrlEntry::new("http://eu-mirror1.example.com/file.bin")
559 .with_priority(5)
560 .with_location("eu"),
561 UrlEntry::new("http://eu-mirror2.example.com/file.bin")
562 .with_priority(5)
563 .with_location("eu"),
564 UrlEntry::new("http://jp-mirror1.example.com/file.bin")
565 .with_priority(5)
566 .with_location("jp"),
567 ];
568
569 let sorted = select_mirrors_by_priority(&urls, "eu");
571
572 assert_eq!(sorted.len(), 4, "Should return all URLs");
573
574 let eu_urls: Vec<_> = sorted
576 .iter()
577 .filter(|u| u.location.as_deref() == Some("eu"))
578 .collect();
579
580 assert_eq!(eu_urls.len(), 2, "Should find 2 EU mirrors");
581
582 let first_non_eu_idx = sorted
584 .iter()
585 .position(|u| u.location.as_deref() != Some("eu"))
586 .expect("Should find at least one non-EU mirror");
587
588 let last_eu_idx = sorted
589 .iter()
590 .rposition(|u| u.location.as_deref() == Some("eu"))
591 .expect("Should find EU mirrors");
592
593 assert!(
594 last_eu_idx < first_non_eu_idx,
595 "EU mirrors should come before non-EU mirrors"
596 );
597
598 let sorted_us = select_mirrors_by_priority(&urls, "us");
600 let us_first = &sorted_us[0];
601 assert_eq!(
602 us_first.location.as_deref(),
603 Some("us"),
604 "US mirror should be first when preferring US"
605 );
606 }
607
608 #[tokio::test]
613 async fn test_failover_tries_all_then_errors() {
614 let urls = [
615 UrlEntry::new("http://mirror1.fail/file.bin").with_priority(3),
616 UrlEntry::new("http://mirror2.fail/file.bin").with_priority(2),
617 UrlEntry::new("http://mirror3.fail/file.bin").with_priority(1),
618 ];
619
620 let fail_fn = |url: &str| -> std::pin::Pin<
622 Box<dyn std::future::Future<Output = std::result::Result<Vec<u8>, String>> + '_>,
623 > {
624 let url_owned = url.to_string();
625 Box::pin(async move { Err(format!("Connection refused to {}", url_owned)) })
626 };
627
628 let url_refs: Vec<&UrlEntry> = urls.iter().collect();
629
630 let result = try_mirrors_with_failover(&url_refs, fail_fn).await;
631
632 assert!(result.is_err(), "Should return error when all mirrors fail");
633
634 let error_msg = result.unwrap_err();
635 assert!(
636 error_msg.contains("All 3 mirrors failed"),
637 "Error message should indicate all 3 mirrors failed"
638 );
639 }
640
641 #[tokio::test]
646 async fn test_single_mirror_no_failover_needed() {
647 let urls =
648 [UrlEntry::new("http://working-mirror.example.com/success.bin").with_priority(10)];
649
650 let expected_data = b"Downloaded file content".to_vec();
651
652 let data_shared = std::sync::Arc::new(expected_data.clone());
655 let success_fn = move |_url: &str| {
656 let data = data_shared.clone();
657 async move { Ok((*data).clone()) }
658 };
659
660 let result = try_mirrors_with_failover(&urls.iter().collect::<Vec<_>>(), &success_fn).await;
661
662 assert!(result.is_ok(), "Single working mirror should succeed");
663
664 let downloaded_data = result.unwrap();
665 assert_eq!(
666 downloaded_data, expected_data,
667 "Downloaded data should match expected content"
668 );
669 assert_eq!(
670 downloaded_data.len(),
671 expected_data.len(),
672 "Should download exactly {} bytes",
673 expected_data.len()
674 );
675 }
676
677 #[test]
683 fn test_priority_overrides_location() {
684 let urls = vec![
685 UrlEntry::new("http://low-eu.example.com/file.bin")
686 .with_priority(1)
687 .with_location("eu"), UrlEntry::new("http://high-us.example.com/file.bin")
689 .with_priority(10)
690 .with_location("us"), ];
692
693 let sorted = select_mirrors_by_priority(&urls, "eu");
694
695 assert_eq!(
697 sorted[0].priority, 10,
698 "Higher priority URL should come first regardless of location match"
699 );
700 assert_eq!(
701 sorted[0].url, "http://high-us.example.com/file.bin",
702 "First should be high-priority US URL"
703 );
704 assert_eq!(
705 sorted[1].priority, 1,
706 "Lower priority URL should come second"
707 );
708 }
709
710 #[test]
712 fn test_empty_resources_returns_empty() {
713 let urls: Vec<UrlEntry> = Vec::new();
714 let sorted = select_mirrors_by_priority(&urls, "");
715
716 assert!(sorted.is_empty(), "Empty input should produce empty output");
717 }
718
719 #[tokio::test]
721 async fn test_failover_succeeds_on_second_mirror() {
722 let urls = [
723 UrlEntry::new("http://failing-mirror.example.com/file.bin").with_priority(5),
724 UrlEntry::new("http://working-mirror.example.com/file.bin").with_priority(3),
725 ];
726
727 let attempt_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
728 let count_clone = attempt_count.clone();
729 let fallback_fn = move |url: &str| {
730 let url_owned = url.to_string();
731 let count = count_clone.clone();
732 async move {
733 count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
734 if url_owned.contains("failing") {
735 Err("Connection timeout".to_string())
736 } else {
737 Ok(b"Success data".to_vec())
738 }
739 }
740 };
741
742 let result =
743 try_mirrors_with_failover(&urls.iter().collect::<Vec<_>>(), &fallback_fn).await;
744
745 assert!(result.is_ok(), "Should succeed on second mirror");
746 assert_eq!(
747 attempt_count.load(std::sync::atomic::Ordering::SeqCst),
748 2,
749 "Should have attempted 2 mirrors"
750 );
751
752 let data = result.unwrap();
753 assert_eq!(
754 data, b"Success data",
755 "Data from second mirror should be returned"
756 );
757 }
758}