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#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Magnet {
13 pub info_hashes: InfoHashes,
15 pub display_name: Option<String>,
17 pub trackers: Vec<String>,
19 pub peers: Vec<String>,
21 pub selected_files: Option<Vec<FileSelection>>,
23}
24
25impl Magnet {
26 #[must_use]
30 pub fn info_hash(&self) -> Id20 {
31 self.info_hashes.best_v1()
32 }
33
34 #[must_use]
36 pub fn is_v2(&self) -> bool {
37 self.info_hashes.has_v2()
38 }
39
40 #[must_use]
42 pub fn is_hybrid(&self) -> bool {
43 self.info_hashes.is_hybrid()
44 }
45
46 pub fn parse(uri: &str) -> Result<Self, Error> {
52 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 }
94 _ => {} }
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 #[must_use]
120 pub fn to_uri(&self) -> String {
121 let mut parts = Vec::new();
122
123 if let Some(v1) = self.info_hashes.v1 {
125 parts.push(format!("magnet:?xt=urn:btih:{}", v1.to_hex()));
126 }
127
128 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
156fn 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
168fn 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()); }
221
222 #[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 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}