aria2_core/engine/
magnet_download_command.rs1use async_trait::async_trait;
2use std::sync::Arc;
3use std::time::Duration;
4use tracing::{info, warn};
5
6use crate::engine::command::{Command, CommandStatus};
7use crate::engine::metadata_exchange::{MetadataExchangeConfig, MetadataExchangeSession};
8use crate::error::{Aria2Error, FatalError, RecoverableError, Result};
9use crate::request::request_group::{DownloadOptions, GroupId, RequestGroup};
10
11pub struct MagnetDownloadCommand {
12 group: Arc<tokio::sync::RwLock<RequestGroup>>,
13 magnet_uri: String,
14 output_path: std::path::PathBuf,
15 started: bool,
16 completed_bytes: u64,
17 dht_engine: Option<std::sync::Arc<aria2_protocol::bittorrent::dht::engine::DhtEngine>>,
18}
19
20impl MagnetDownloadCommand {
21 pub fn new(
22 gid: GroupId,
23 magnet_uri: &str,
24 options: &DownloadOptions,
25 output_dir: Option<&str>,
26 ) -> Result<Self> {
27 let _ml =
28 aria2_protocol::bittorrent::magnet::MagnetLink::parse(magnet_uri).map_err(|e| {
29 Aria2Error::Fatal(FatalError::Config(format!("Invalid magnet link: {}", e)))
30 })?;
31
32 let dir = output_dir
33 .map(|d| d.to_string())
34 .or_else(|| options.dir.clone())
35 .unwrap_or_else(|| ".".to_string());
36
37 let filename = _ml
38 .display_name
39 .as_deref()
40 .unwrap_or("magnet_download")
41 .to_string();
42 let path = std::path::PathBuf::from(&dir).join(&filename);
43
44 let urls = vec![magnet_uri.to_string()];
45 let group = RequestGroup::new(gid, urls, options.clone());
46
47 info!(
48 "MagnetDownloadCommand created: {} -> {} (hash={})",
49 filename,
50 path.display(),
51 _ml.info_hash_hex()
52 );
53
54 Ok(Self {
55 group: Arc::new(tokio::sync::RwLock::new(group)),
56 magnet_uri: magnet_uri.to_string(),
57 output_path: path,
58 started: false,
59 completed_bytes: 0,
60 dht_engine: None,
61 })
62 }
63
64 pub async fn group(&self) -> tokio::sync::RwLockReadGuard<'_, RequestGroup> {
65 self.group.read().await
66 }
67
68 async fn enforce_bep0027_after_metadata(&mut self, torrent_bytes: &[u8]) -> Result<()> {
86 let is_private =
87 aria2_protocol::bittorrent::torrent::parser::TorrentMeta::parse(torrent_bytes)
88 .map_err(|e| {
89 Aria2Error::Fatal(FatalError::Config(format!(
90 "Fetched metadata parse failed: {}",
91 e
92 )))
93 })?
94 .is_private();
95
96 if is_private {
97 info!("Private torrent detected after metadata exchange: shutting down DHT (BEP 0027)");
98 if let Some(ref engine) = self.dht_engine {
99 engine.shutdown_async().await;
100 }
101 self.dht_engine = None;
106 }
107
108 Ok(())
109 }
110}
111
112#[async_trait]
113impl Command for MagnetDownloadCommand {
114 async fn execute(&mut self) -> Result<()> {
115 if !self.started {
116 self.group.write().await.start().await?;
117 self.started = true;
118 }
119
120 let ml = aria2_protocol::bittorrent::magnet::MagnetLink::parse(&self.magnet_uri).map_err(
121 |e| Aria2Error::Fatal(FatalError::Config(format!("Magnet parse error: {}", e))),
122 )?;
123
124 info!(
125 "Magnet download: hash={}, name={:?}",
126 ml.info_hash_hex(),
127 ml.display_name
128 );
129
130 if let Some(parent) = self.output_path.parent()
131 && !parent.exists()
132 {
133 std::fs::create_dir_all(parent).map_err(|e| {
134 Aria2Error::Fatal(FatalError::Config(format!("mkdir failed: {}", e)))
135 })?;
136 }
137
138 let enable_dht = { self.group.read().await.options().enable_dht };
139 let dht_port = { self.group.read().await.options().dht_listen_port };
140
141 if enable_dht && self.dht_engine.is_none() {
142 let dht_config = aria2_protocol::bittorrent::dht::engine::DhtEngineConfig {
143 port: dht_port.unwrap_or(0),
144 ..Default::default()
145 };
146 match aria2_protocol::bittorrent::dht::engine::DhtEngine::start(dht_config).await {
147 Ok(engine) => {
148 self.dht_engine = Some(engine);
149 self.dht_engine.as_ref().unwrap().start_maintenance_loop();
150 info!("Magnet: DHT engine started for peer discovery");
151 }
152 Err(e) => {
153 warn!("Magnet: DHT engine start failed: {}", e);
154 }
155 }
156 }
157
158 let discovered_peers = if let Some(ref engine) = self.dht_engine {
159 let result = engine.find_peers(&ml.info_hash).await;
160 info!(
161 "Magnet: DHT discovered {} peers (contacted {} nodes)",
162 result.peers.len(),
163 result.nodes_contacted
164 );
165 result.peers
166 } else {
167 warn!("Magnet: DHT disabled, no peers available");
168 vec![]
169 };
170
171 if discovered_peers.is_empty() {
172 return Err(Aria2Error::Recoverable(
173 RecoverableError::TemporaryNetworkFailure {
174 message: "No peers found via DHT".into(),
175 },
176 ));
177 }
178
179 let meta_session = MetadataExchangeSession::new(MetadataExchangeConfig {
180 max_peers_to_try: discovered_peers.len().min(5),
181 connect_timeout: Duration::from_secs(15),
182 request_timeout: Duration::from_secs(10),
183 piece_size: 16 * 1024,
184 ..MetadataExchangeConfig::default()
185 });
186
187 let torrent_bytes = meta_session
188 .fetch_metadata(&ml.info_hash, &discovered_peers)
189 .await
190 .map_err(|e| {
191 Aria2Error::Recoverable(RecoverableError::TemporaryNetworkFailure {
192 message: format!("Metadata fetch failed: {}", e),
193 })
194 })?;
195
196 info!("Fetched torrent metadata: {} bytes", torrent_bytes.len());
197
198 self.enforce_bep0027_after_metadata(&torrent_bytes).await?;
204
205 use crate::engine::bt_download_command::BtDownloadCommand;
206 let mut bt_cmd = BtDownloadCommand::new(
207 self.group.read().await.gid(),
208 &torrent_bytes,
209 &DownloadOptions::default(),
210 self.output_path.parent().and_then(|p| p.to_str()),
211 )?;
212
213 bt_cmd.execute().await?;
214
215 if let Some(ref engine) = self.dht_engine {
216 engine.shutdown();
217 }
218
219 self.completed_bytes = self.group.read().await.total_length();
220
221 info!("Magnet download complete: {}", self.output_path.display());
222 Ok(())
223 }
224
225 fn status(&self) -> CommandStatus {
226 if self.completed_bytes > 0 {
227 CommandStatus::Running
228 } else {
229 CommandStatus::Pending
230 }
231 }
232
233 fn timeout(&self) -> Option<Duration> {
234 Some(Duration::from_secs(900))
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use crate::engine::bt_download_command_tests::tests::{
242 build_private_test_torrent, build_test_torrent,
243 };
244
245 const TEST_MAGNET_URI: &str =
247 "magnet:?xt=urn:btih:abc123def45678901234567890abcdef12345678&dn=test_file";
248
249 fn make_test_command() -> MagnetDownloadCommand {
250 MagnetDownloadCommand::new(
251 GroupId::new(1),
252 TEST_MAGNET_URI,
253 &DownloadOptions::default(),
254 None,
255 )
256 .expect("Failed to create test MagnetDownloadCommand")
257 }
258
259 async fn start_test_dht_engine()
261 -> std::sync::Arc<aria2_protocol::bittorrent::dht::engine::DhtEngine> {
262 aria2_protocol::bittorrent::dht::engine::DhtEngine::start(
263 aria2_protocol::bittorrent::dht::engine::DhtEngineConfig::default(),
264 )
265 .await
266 .expect("Failed to start DhtEngine for test")
267 }
268
269 #[tokio::test]
274 async fn test_magnet_private_torrent_dht_shutdown_after_metadata() {
275 let mut cmd = make_test_command();
276 cmd.dht_engine = Some(start_test_dht_engine().await);
277 assert!(cmd.dht_engine.is_some(), "precondition: DHT engine present");
278
279 let torrent_bytes = build_private_test_torrent();
280
281 cmd.enforce_bep0027_after_metadata(&torrent_bytes)
282 .await
283 .expect("enforce_bep0027 should succeed for private torrent");
284
285 assert!(
286 cmd.dht_engine.is_none(),
287 "DHT engine must be None after private torrent metadata (BEP 0027)"
288 );
289 }
290
291 #[tokio::test]
295 async fn test_magnet_public_torrent_dht_continues() {
296 let mut cmd = make_test_command();
297 let engine = start_test_dht_engine().await;
298 cmd.dht_engine = Some(engine.clone());
299
300 let torrent_bytes = build_test_torrent();
301
302 cmd.enforce_bep0027_after_metadata(&torrent_bytes)
303 .await
304 .expect("enforce_bep0027 should succeed for public torrent");
305
306 assert!(
307 cmd.dht_engine.is_some(),
308 "DHT engine must remain active for public torrent"
309 );
310
311 engine.shutdown_async().await;
313 }
314
315 #[tokio::test]
319 async fn test_magnet_enforce_bep0027_no_dht_engine() {
320 let mut cmd = make_test_command();
321 assert!(cmd.dht_engine.is_none(), "precondition: no DHT engine");
322
323 let torrent_bytes = build_private_test_torrent();
324
325 cmd.enforce_bep0027_after_metadata(&torrent_bytes)
326 .await
327 .expect("should succeed even when DHT engine is absent");
328
329 assert!(cmd.dht_engine.is_none());
330 }
331
332 #[tokio::test]
336 async fn test_magnet_enforce_bep0027_invalid_metadata_errors() {
337 let mut cmd = make_test_command();
338
339 let bad_bytes: &[u8] = b"this is not valid bencode";
340
341 let result = cmd.enforce_bep0027_after_metadata(bad_bytes).await;
342 assert!(
343 result.is_err(),
344 "Invalid metadata bytes must return an error, not silently default to public"
345 );
346
347 assert!(
351 cmd.dht_engine.is_none(),
352 "DHT engine field should be unchanged on parse error"
353 );
354 }
355}