Skip to main content

aria2_core/engine/
magnet_download_command.rs

1use 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    /// BEP 0027 (Private Torrent) enforcement after metadata exchange.
69    ///
70    /// For magnet links, the DHT engine is started BEFORE metadata arrives
71    /// because DHT-based peer discovery is required to find peers that can
72    /// serve the metadata via BEP 0010 (Extension for Peers to Send Metadata
73    /// File). Once the metadata has been fetched, if the torrent's `private`
74    /// flag is set, DHT must be shut down to comply with BEP 0027 which
75    /// forbids DHT, PEX, and LPD for private torrents.
76    ///
77    /// This method parses the fetched `torrent_bytes`, checks `is_private()`,
78    /// and if true, shuts down the DHT engine asynchronously and clears the
79    /// `dht_engine` field so the downstream `BtDownloadCommand` (created from
80    /// the same bytes) will not see a running DHT engine and will itself
81    /// honour BEP 0027 by not starting a new one.
82    ///
83    /// Extracted as a standalone async method so the policy logic can be
84    /// unit tested without mocking the BEP 0010 metadata exchange network I/O.
85    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            // Clear the field so the trailing shutdown() call in execute()
102            // does not attempt to shut down an already-stopped engine, and
103            // so the downstream BtDownloadCommand cannot accidentally reuse
104            // the engine.
105            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        // BEP 0027 (Private Torrent): DHT was started before metadata exchange
199        // for peer discovery. Now that the metadata is available, parse it and
200        // shut down DHT if the torrent is private. The downstream
201        // BtDownloadCommand (created from the same bytes) will also enforce
202        // BEP 0027 by refusing to start its own DHT when is_private is set.
203        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    /// Valid 40-char hex info-hash magnet link used by all test cases.
246    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    /// Start a real DhtEngine on an ephemeral port (port 0) for testing.
260    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    /// BEP 0027: After metadata exchange, a private torrent must cause the
270    /// DHT engine (started for peer discovery) to be shut down and the
271    /// `dht_engine` field cleared so the downstream `BtDownloadCommand`
272    /// cannot accidentally reuse it.
273    #[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    /// BEP 0027: A public torrent (no `private` flag) must NOT trigger DHT
292    /// shutdown — the DHT engine started for peer discovery remains active
293    /// so the downstream download can continue using it.
294    #[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        // Clean up: shut down the still-running engine to release the socket.
312        engine.shutdown_async().await;
313    }
314
315    /// When DHT was never started (e.g. enable_dht = false), the enforcement
316    /// method must still parse the metadata and succeed without error. There
317    /// is nothing to shut down, so `dht_engine` stays `None`.
318    #[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    /// Corrupt metadata bytes must produce a fatal config error rather than
333    /// silently treating the torrent as public (which would leak DHT usage
334    /// for what might actually be a private torrent).
335    #[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        // DHT engine should be untouched when parsing fails (fail-closed on
348        // the parse error, but we do not preemptively shut down DHT since the
349        // caller may want to retry metadata fetch from a different peer).
350        assert!(
351            cmd.dht_engine.is_none(),
352            "DHT engine field should be unchanged on parse error"
353        );
354    }
355}