Skip to main content

irontide_core/
magnet.rs

1use url::Url;
2
3use crate::error::Error;
4use crate::file_selection::FileSelection;
5use crate::hash::{Id20, Id32};
6use crate::info_hashes::InfoHashes;
7
8/// Parsed magnet link (BEP 9 + BEP 52).
9///
10/// Supports v1 (`urn:btih:`), v2 (`urn:btmh:`), and hybrid magnets.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Magnet {
13    /// Unified info hashes (v1 and/or v2).
14    pub info_hashes: InfoHashes,
15    /// Display name (optional).
16    pub display_name: Option<String>,
17    /// Tracker URLs.
18    pub trackers: Vec<String>,
19    /// Peer addresses (x.pe parameter).
20    pub peers: Vec<String>,
21    /// BEP 53 file selection (optional `so=` parameter).
22    pub selected_files: Option<Vec<FileSelection>>,
23}
24
25impl Magnet {
26    /// Get the best v1 info hash for backward compatibility.
27    ///
28    /// Returns the v1 hash directly, or truncates v2 if only v2 is available.
29    #[must_use]
30    pub fn info_hash(&self) -> Id20 {
31        self.info_hashes.best_v1()
32    }
33
34    /// Whether this magnet contains a v2 hash.
35    #[must_use]
36    pub fn is_v2(&self) -> bool {
37        self.info_hashes.has_v2()
38    }
39
40    /// Whether this magnet contains both v1 and v2 hashes.
41    #[must_use]
42    pub fn is_hybrid(&self) -> bool {
43        self.info_hashes.is_hybrid()
44    }
45
46    /// Parse a magnet URI string.
47    ///
48    /// # Errors
49    ///
50    /// Returns an error if the URI is not a valid magnet link.
51    pub fn parse(uri: &str) -> Result<Self, Error> {
52        // Magnet URIs use "magnet:?" which isn't a valid URL scheme for most parsers.
53        // We replace "magnet:?" with "magnet://dummy?" to make it parseable.
54        let normalized = if let Some(rest) = uri.strip_prefix("magnet:?") {
55            format!("magnet://dummy?{rest}")
56        } else {
57            return Err(Error::InvalidMagnet("must start with 'magnet:?'".into()));
58        };
59
60        let url = Url::parse(&normalized)
61            .map_err(|e| Error::InvalidMagnet(format!("URL parse error: {e}")))?;
62
63        let mut v1_hash: Option<Id20> = None;
64        let mut v2_hash: Option<Id32> = None;
65        let mut display_name = None;
66        let mut trackers = Vec::new();
67        let mut peers = Vec::new();
68        let mut selected_files = None;
69
70        for (key, value) in url.query_pairs() {
71            match key.as_ref() {
72                "xt" => {
73                    if let Some(hash_str) = value.strip_prefix("urn:btih:") {
74                        v1_hash = Some(parse_v1_hash(hash_str)?);
75                    } else if let Some(hash_str) = value.strip_prefix("urn:btmh:") {
76                        v2_hash = Some(parse_v2_multihash(hash_str)?);
77                    }
78                }
79                "dn" => {
80                    display_name = Some(value.into_owned());
81                }
82                "tr" => {
83                    trackers.push(value.into_owned());
84                }
85                "x.pe" => {
86                    peers.push(value.into_owned());
87                }
88                "so" => {
89                    if let Ok(sels) = FileSelection::parse(&value) {
90                        selected_files = Some(sels);
91                    }
92                    // Ignore malformed so= values per BEP 53
93                }
94                _ => {} // Ignore unknown parameters
95            }
96        }
97
98        if v1_hash.is_none() && v2_hash.is_none() {
99            return Err(Error::InvalidMagnet(
100                "missing xt=urn:btih: or xt=urn:btmh:".into(),
101            ));
102        }
103
104        let info_hashes = InfoHashes {
105            v1: v1_hash,
106            v2: v2_hash,
107        };
108
109        Ok(Self {
110            info_hashes,
111            display_name,
112            trackers,
113            peers,
114            selected_files,
115        })
116    }
117
118    /// Convert back to a magnet URI string.
119    #[must_use]
120    pub fn to_uri(&self) -> String {
121        let mut parts = Vec::new();
122
123        // Emit v1 hash if present
124        if let Some(v1) = self.info_hashes.v1 {
125            parts.push(format!("magnet:?xt=urn:btih:{}", v1.to_hex()));
126        }
127
128        // Emit v2 hash if present
129        if let Some(v2) = self.info_hashes.v2 {
130            let prefix = if parts.is_empty() { "magnet:?" } else { "" };
131            parts.push(format!("{}xt=urn:btmh:{}", prefix, v2.to_multihash_hex()));
132        }
133
134        if let Some(ref name) = self.display_name {
135            parts.push(format!(
136                "dn={}",
137                url::form_urlencoded::byte_serialize(name.as_bytes()).collect::<String>()
138            ));
139        }
140
141        for tracker in &self.trackers {
142            parts.push(format!(
143                "tr={}",
144                url::form_urlencoded::byte_serialize(tracker.as_bytes()).collect::<String>()
145            ));
146        }
147
148        if let Some(ref sels) = self.selected_files {
149            parts.push(format!("so={}", FileSelection::to_so_value(sels)));
150        }
151
152        parts.join("&")
153    }
154}
155
156/// Parse a v1 info hash from hex (40 chars) or base32 (32 chars).
157fn parse_v1_hash(s: &str) -> Result<Id20, Error> {
158    match s.len() {
159        40 => Id20::from_hex(s),
160        32 => Id20::from_base32(&s.to_ascii_uppercase()),
161        _ => Err(Error::InvalidMagnet(format!(
162            "v1 info hash must be 40 hex or 32 base32 chars, got {} chars",
163            s.len()
164        ))),
165    }
166}
167
168/// Parse a v2 multihash from hex (68 chars = "1220" prefix + 64 hex).
169fn parse_v2_multihash(s: &str) -> Result<Id32, Error> {
170    Id32::from_multihash_hex(s).map_err(|e| Error::InvalidMagnet(format!("v2 multihash: {e}")))
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn parse_hex_magnet() {
179        let uri = "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d&dn=test&tr=http://tracker.example.com/announce";
180        let m = Magnet::parse(uri).unwrap();
181        assert_eq!(
182            m.info_hash(),
183            Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap()
184        );
185        assert_eq!(m.display_name.as_deref(), Some("test"));
186        assert_eq!(m.trackers, vec!["http://tracker.example.com/announce"]);
187    }
188
189    #[test]
190    fn parse_base32_magnet() {
191        let id = Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap();
192        let b32 = id.to_base32();
193        let uri = format!("magnet:?xt=urn:btih:{b32}");
194        let m = Magnet::parse(&uri).unwrap();
195        assert_eq!(m.info_hash(), id);
196    }
197
198    #[test]
199    fn parse_multiple_trackers() {
200        let uri = "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d&tr=http://a.com&tr=http://b.com";
201        let m = Magnet::parse(uri).unwrap();
202        assert_eq!(m.trackers.len(), 2);
203    }
204
205    #[test]
206    fn round_trip_uri() {
207        let uri = "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d&dn=test%20file&tr=http%3A%2F%2Ftracker.example.com%2Fannounce";
208        let m = Magnet::parse(uri).unwrap();
209        let rebuilt = m.to_uri();
210        let m2 = Magnet::parse(&rebuilt).unwrap();
211        assert_eq!(m.info_hash(), m2.info_hash());
212        assert_eq!(m.display_name, m2.display_name);
213        assert_eq!(m.trackers, m2.trackers);
214    }
215
216    #[test]
217    fn reject_invalid_magnet() {
218        assert!(Magnet::parse("http://example.com").is_err());
219        assert!(Magnet::parse("magnet:?dn=test").is_err()); // no xt
220    }
221
222    // v2-specific tests
223
224    #[test]
225    fn parse_v2_only_magnet() {
226        let hash =
227            Id32::from_hex("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
228                .unwrap();
229        let mh = hash.to_multihash_hex();
230        let uri = format!("magnet:?xt=urn:btmh:{mh}&dn=v2test");
231        let m = Magnet::parse(&uri).unwrap();
232        assert!(m.is_v2());
233        assert!(!m.is_hybrid());
234        assert_eq!(m.info_hashes.v2, Some(hash));
235        assert_eq!(m.display_name.as_deref(), Some("v2test"));
236    }
237
238    #[test]
239    fn parse_hybrid_magnet() {
240        let v1 = Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap();
241        let v2 = Id32::from_hex("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
242            .unwrap();
243        let uri = format!(
244            "magnet:?xt=urn:btih:{}&xt=urn:btmh:{}",
245            v1.to_hex(),
246            v2.to_multihash_hex()
247        );
248        let m = Magnet::parse(&uri).unwrap();
249        assert!(m.is_hybrid());
250        assert_eq!(m.info_hashes.v1, Some(v1));
251        assert_eq!(m.info_hashes.v2, Some(v2));
252        // info_hash() backward compat returns v1
253        assert_eq!(m.info_hash(), v1);
254    }
255
256    #[test]
257    fn v2_round_trip() {
258        let hash =
259            Id32::from_hex("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
260                .unwrap();
261        let mh = hash.to_multihash_hex();
262        let uri = format!("magnet:?xt=urn:btmh:{mh}");
263        let m = Magnet::parse(&uri).unwrap();
264        let rebuilt = m.to_uri();
265        let m2 = Magnet::parse(&rebuilt).unwrap();
266        assert_eq!(m.info_hashes, m2.info_hashes);
267    }
268
269    #[test]
270    fn hybrid_round_trip() {
271        let v1 = Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap();
272        let v2 = Id32::from_hex("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
273            .unwrap();
274        let uri = format!(
275            "magnet:?xt=urn:btih:{}&xt=urn:btmh:{}",
276            v1.to_hex(),
277            v2.to_multihash_hex()
278        );
279        let m = Magnet::parse(&uri).unwrap();
280        let rebuilt = m.to_uri();
281        let m2 = Magnet::parse(&rebuilt).unwrap();
282        assert_eq!(m.info_hashes, m2.info_hashes);
283    }
284
285    #[test]
286    fn reject_no_hash() {
287        assert!(Magnet::parse("magnet:?dn=test").is_err());
288    }
289
290    #[test]
291    fn parse_so_parameter() {
292        let uri = "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d&so=0,2,4-6";
293        let m = Magnet::parse(uri).unwrap();
294        let sels = m.selected_files.unwrap();
295        assert_eq!(
296            sels,
297            vec![
298                crate::file_selection::FileSelection::Single(0),
299                crate::file_selection::FileSelection::Single(2),
300                crate::file_selection::FileSelection::Range(4, 6),
301            ]
302        );
303    }
304
305    #[test]
306    fn so_parameter_round_trip() {
307        let uri = "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d&so=0,2,4-6";
308        let m = Magnet::parse(uri).unwrap();
309        let rebuilt = m.to_uri();
310        let m2 = Magnet::parse(&rebuilt).unwrap();
311        assert_eq!(m.selected_files, m2.selected_files);
312    }
313
314    #[test]
315    fn no_so_parameter_is_none() {
316        let uri = "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d";
317        let m = Magnet::parse(uri).unwrap();
318        assert!(m.selected_files.is_none());
319    }
320
321    #[test]
322    fn invalid_so_parameter_ignored() {
323        let uri = "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d&so=abc";
324        let m = Magnet::parse(uri).unwrap();
325        assert!(m.selected_files.is_none());
326    }
327
328    #[test]
329    fn v1_only_backward_compat() {
330        let uri = "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d";
331        let m = Magnet::parse(uri).unwrap();
332        assert!(!m.is_v2());
333        assert!(!m.is_hybrid());
334        assert_eq!(
335            m.info_hash(),
336            Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap()
337        );
338    }
339}