Skip to content

Commit 2a70b9d

Browse files
authored
Percent encode all magnet link query parameters (#556)
1 parent 5c39923 commit 2a70b9d

File tree

6 files changed

+131
-32
lines changed

6 files changed

+131
-32
lines changed

Cargo.lock

Lines changed: 4 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ libc = "0.2.0"
2929
log = "0.4.8"
3030
md5 = "0.7.0"
3131
open = "5.0.1"
32+
percent-encoding = "2.3.2"
3233
pretty_assertions = "1.4.0"
3334
pretty_env_logger = "0.5.0"
3435
rand = "0.8.5"
@@ -73,6 +74,7 @@ temptree = "0.2.0"
7374
[lints.clippy]
7475
all = { level = "deny", priority = -1 }
7576
float_cmp = "allow"
77+
format_collect = "allow"
7678
ignore_without_reason = "allow"
7779
large_enum_variant = "allow"
7880
needless_pass_by_value = "allow"

justfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ done BRANCH=`git rev-parse --abbrev-ref HEAD`:
2929
git rebase github/master master
3030
git branch -d {{BRANCH}}
3131

32+
ci: clippy forbid test
33+
cargo fmt -- --check
34+
cargo test --all -- --ignored
35+
3236
test:
3337
cargo test --all
3438

src/magnet_link.rs

Lines changed: 116 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
use {crate::common::*, url::form_urlencoded::byte_serialize as urlencode};
1+
use crate::common::*;
22

33
#[derive(Clone, Debug, PartialEq)]
44
pub(crate) struct MagnetLink {
5+
pub(crate) indices: BTreeSet<u64>,
56
pub(crate) infohash: Infohash,
67
pub(crate) name: Option<String>,
78
pub(crate) peers: Vec<HostPort>,
89
pub(crate) trackers: Vec<Url>,
9-
pub(crate) indices: BTreeSet<u64>,
1010
}
1111

1212
impl MagnetLink {
@@ -33,7 +33,6 @@ impl MagnetLink {
3333
}
3434
}
3535

36-
#[allow(dead_code)]
3736
pub(crate) fn set_name(&mut self, name: impl Into<String>) {
3837
self.name = Some(name.into());
3938
}
@@ -55,31 +54,33 @@ impl MagnetLink {
5554

5655
let mut query = format!("xt=urn:btih:{}", self.infohash);
5756

57+
let mut append = |key: &str, value: &str| {
58+
query.push('&');
59+
query.push_str(key);
60+
query.push('=');
61+
query.push_str(&Self::percent_encode_query_param(value));
62+
};
63+
5864
if let Some(name) = &self.name {
59-
query.push_str("&dn=");
60-
query.push_str(name);
65+
append("dn", name);
6166
}
6267

6368
for tracker in &self.trackers {
64-
query.push_str("&tr=");
65-
for part in urlencode(tracker.as_str().as_bytes()) {
66-
query.push_str(part);
67-
}
69+
append("tr", tracker.as_str());
6870
}
6971

7072
for peer in &self.peers {
71-
query.push_str("&x.pe=");
72-
query.push_str(&peer.to_string());
73+
append("x.pe", &peer.to_string());
7374
}
7475

7576
if !self.indices.is_empty() {
76-
query.push_str("&so=");
77-
for (i, selection_index) in self.indices.iter().enumerate() {
78-
if i > 0 {
79-
query.push(',');
80-
}
81-
query.push_str(&selection_index.to_string());
82-
}
77+
let indices = self
78+
.indices
79+
.iter()
80+
.map(ToString::to_string)
81+
.collect::<Vec<String>>()
82+
.join(",");
83+
append("so", &indices);
8384
}
8485

8586
url.set_query(Some(&query));
@@ -146,6 +147,27 @@ impl MagnetLink {
146147

147148
Ok(link)
148149
}
150+
151+
fn percent_encode_query_param(s: &str) -> String {
152+
const ENCODE: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
153+
.add(b' ')
154+
.add(b'"')
155+
.add(b'#')
156+
.add(b'%')
157+
.add(b'&')
158+
.add(b'<')
159+
.add(b'=')
160+
.add(b'>')
161+
.add(b'[')
162+
.add(b'\\')
163+
.add(b']')
164+
.add(b'^')
165+
.add(b'`')
166+
.add(b'{')
167+
.add(b'|')
168+
.add(b'}');
169+
percent_encoding::utf8_percent_encode(s, ENCODE).to_string()
170+
}
149171
}
150172

151173
impl FromStr for MagnetLink {
@@ -212,7 +234,7 @@ mod tests {
212234
link.add_tracker(Url::parse("http://foo.com/announce").unwrap());
213235
assert_eq!(
214236
link.to_url().as_str(),
215-
"magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709&tr=http%3A%2F%2Ffoo.com%2Fannounce"
237+
"magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709&tr=http://foo.com/announce"
216238
);
217239
}
218240

@@ -242,8 +264,8 @@ mod tests {
242264
concat!(
243265
"magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709",
244266
"&dn=foo",
245-
"&tr=http%3A%2F%2Ffoo.com%2Fannounce",
246-
"&tr=http%3A%2F%2Fbar.net%2Fannounce",
267+
"&tr=http://foo.com/announce",
268+
"&tr=http://bar.net/announce",
247269
"&x.pe=foo.com:1337",
248270
"&x.pe=bar.net:666",
249271
),
@@ -270,8 +292,8 @@ mod tests {
270292
let magnet_str = concat!(
271293
"magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709",
272294
"&dn=foo",
273-
"&tr=http%3A%2F%2Ffoo.com%2Fannounce",
274-
"&tr=http%3A%2F%2Fbar.net%2Fannounce"
295+
"&tr=http://foo.com/announce",
296+
"&tr=http://bar.net/announce"
275297
);
276298

277299
let link_from = MagnetLink::from_str(magnet_str).unwrap();
@@ -284,7 +306,7 @@ mod tests {
284306
let magnet_str = concat!(
285307
"magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709",
286308
"&dn=foo",
287-
"&tr=http%3A%2F%2Ffoo.com%2Fannounce",
309+
"&tr=http://foo.com/announce",
288310
);
289311

290312
let link_from = MagnetLink::from_str(magnet_str).unwrap();
@@ -390,4 +412,74 @@ mod tests {
390412
} if text == link && addr == bad_addr
391413
);
392414
}
415+
416+
#[test]
417+
fn magnet_link_query_params_are_percent_encoded() {
418+
let mut e = "magnet:?xt=urn:btih:0000000000000000000000000000000000000000"
419+
.parse::<MagnetLink>()
420+
.unwrap();
421+
e.set_name("foo bar");
422+
e.add_tracker("http://[::]".parse().unwrap());
423+
e.add_peer("[::]:0".parse().unwrap());
424+
425+
assert_eq!(
426+
e.to_url().as_str(),
427+
concat!(
428+
"magnet:",
429+
"?xt=urn:btih:0000000000000000000000000000000000000000",
430+
"&dn=foo%20bar",
431+
"&tr=http://%5B::%5D/",
432+
"&x.pe=%5B::%5D:0",
433+
),
434+
);
435+
}
436+
437+
#[test]
438+
fn percent_encode() {
439+
// Build a string containing all safe characters to test against using the
440+
// `query` grammar from the URL RFC:
441+
//
442+
// https://datatracker.ietf.org/doc/html/rfc3986#section-3.4
443+
//
444+
// `&` and `=` are omitted since they are used to delimit query parameter
445+
// keys and values
446+
447+
// query = *( pchar / "/" / "?" )
448+
let mut safe = "/?".to_string();
449+
450+
// pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
451+
safe.push_str(":@");
452+
453+
// unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
454+
for c in 'a'..='z' {
455+
safe.push(c);
456+
}
457+
458+
for c in 'A'..='Z' {
459+
safe.push(c);
460+
}
461+
462+
for c in '0'..='9' {
463+
safe.push(c);
464+
}
465+
466+
safe.push_str("-._~");
467+
468+
// sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="
469+
safe.push_str("!$'()*+,;");
470+
471+
for c in '\u{0}'..='\u{80}' {
472+
let s = c.to_string();
473+
if safe.contains(c) {
474+
assert_eq!(MagnetLink::percent_encode_query_param(&s), s);
475+
} else {
476+
assert_eq!(
477+
MagnetLink::percent_encode_query_param(&s),
478+
s.bytes()
479+
.map(|byte| format!("%{byte:02X}"))
480+
.collect::<String>(),
481+
);
482+
}
483+
}
484+
}
393485
}

src/subcommand/torrent/create.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2593,7 +2593,7 @@ Content Size 9 bytes
25932593
"magnet:\
25942594
?xt=urn:btih:516735f4b80f2b5487eed5f226075bdcde33a54e\
25952595
&dn=foo\
2596-
&tr=http%3A%2F%2Ffoo.com%2Fannounce\n"
2596+
&tr=http://foo.com/announce\n"
25972597
);
25982598
}
25992599

src/subcommand/torrent/link.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ mod tests {
233233
assert_eq!(
234234
env.out(),
235235
format!(
236-
"magnet:?xt=urn:btih:{}&dn=foo&tr=https%3A%2F%2Ffoo.com%2Fannounce\n",
236+
"magnet:?xt=urn:btih:{}&dn=foo&tr=https://foo.com/announce\n",
237237
infohash
238238
),
239239
);
@@ -266,7 +266,7 @@ mod tests {
266266
assert_eq!(
267267
env.out(),
268268
format!(
269-
"magnet:?xt=urn:btih:{}&dn=foo&tr=https%3A%2F%2Ffoo.com%2Fannounce&tr=https%3A%2F%2Fbar.com%2Fannounce\n",
269+
"magnet:?xt=urn:btih:{}&dn=foo&tr=https://foo.com/announce&tr=https://bar.com/announce\n",
270270
infohash
271271
),
272272
);
@@ -300,7 +300,7 @@ mod tests {
300300
assert_eq!(
301301
env.out(),
302302
format!(
303-
"magnet:?xt=urn:btih:{}&dn=foo&tr=https%3A%2F%2Ffoo.com%2Fannounce&x.pe=foo.com:1337\n",
303+
"magnet:?xt=urn:btih:{}&dn=foo&tr=https://foo.com/announce&x.pe=foo.com:1337\n",
304304
infohash
305305
),
306306
);
@@ -336,7 +336,7 @@ mod tests {
336336
assert_eq!(
337337
env.out(),
338338
format!(
339-
"magnet:?xt=urn:btih:{}&dn=foo&tr=https%3A%2F%2Ffoo.com%2Fannounce&so=2,4,6\n",
339+
"magnet:?xt=urn:btih:{}&dn=foo&tr=https://foo.com/announce&so=2,4,6\n",
340340
infohash
341341
),
342342
);

0 commit comments

Comments
 (0)