From 660709f236c2b8f80c4dac85de2ea10298ed5fc2 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Fri, 7 Nov 2025 23:43:00 -0800 Subject: [PATCH 1/9] Percent encode magnet link components --- Cargo.lock | 7 +-- Cargo.toml | 1 + src/magnet_link.rs | 108 ++++++++++++++++++++++++++++++++++++++------- 3 files changed, 96 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a15a52b..0ddeb55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -808,6 +808,7 @@ dependencies = [ "log", "md5", "open", + "percent-encoding", "pretty_assertions", "pretty_env_logger 0.5.0", "rand", @@ -1125,9 +1126,9 @@ checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pkg-config" diff --git a/Cargo.toml b/Cargo.toml index 9290f9e..4648005 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ libc = "0.2.0" log = "0.4.8" md5 = "0.7.0" open = "5.0.1" +percent-encoding = "2.3.2" pretty_assertions = "1.4.0" pretty_env_logger = "0.5.0" rand = "0.8.5" diff --git a/src/magnet_link.rs b/src/magnet_link.rs index 7d09798..6c3a02b 100644 --- a/src/magnet_link.rs +++ b/src/magnet_link.rs @@ -1,12 +1,33 @@ -use {crate::common::*, url::form_urlencoded::byte_serialize as urlencode}; +use crate::common::*; + +fn percent_encode_query_param(s: &str) -> String { + const QUERY: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'%') + .add(b'&') + .add(b'<') + .add(b'=') + .add(b'>') + .add(b'[') + .add(b'\\') + .add(b']') + .add(b'^') + .add(b'`') + .add(b'{') + .add(b'|') + .add(b'}'); + percent_encoding::utf8_percent_encode(s, QUERY).to_string() +} #[derive(Clone, Debug, PartialEq)] pub(crate) struct MagnetLink { + pub(crate) indices: BTreeSet, pub(crate) infohash: Infohash, pub(crate) name: Option, pub(crate) peers: Vec, pub(crate) trackers: Vec, - pub(crate) indices: BTreeSet, } impl MagnetLink { @@ -55,31 +76,33 @@ impl MagnetLink { let mut query = format!("xt=urn:btih:{}", self.infohash); + let mut append = |key: &str, value: &str| { + query.push('&'); + query.push_str(key); + query.push('='); + query.push_str(&percent_encode_query_param(&value)); + }; + if let Some(name) = &self.name { - query.push_str("&dn="); - query.push_str(name); + append("dn", name); } for tracker in &self.trackers { - query.push_str("&tr="); - for part in urlencode(tracker.as_str().as_bytes()) { - query.push_str(part); - } + append("tr", tracker.as_str()); } for peer in &self.peers { - query.push_str("&x.pe="); - query.push_str(&peer.to_string()); + append("x.pe", &peer.to_string()); } if !self.indices.is_empty() { - query.push_str("&so="); - for (i, selection_index) in self.indices.iter().enumerate() { - if i > 0 { - query.push(','); - } - query.push_str(&selection_index.to_string()); - } + let indices = self + .indices + .iter() + .map(|index| index.to_string()) + .collect::>() + .join(","); + append("so", &indices); } url.set_query(Some(&query)); @@ -390,4 +413,55 @@ mod tests { } if text == link && addr == bad_addr ); } + + #[test] + fn percent_encode() { + // Build a string containing all safe characters to test against using the + // `query` grammar from the URL RFC: + // + // https://datatracker.ietf.org/doc/html/rfc3986#section-3.4 + // + // `%` is omitted since it is used to introduce percent encoded characters + // `&` and `=` are omitted since they are used to delimit query parameter keys and values + + // query = *( pchar / "/" / "?" ) + let mut safe = "/?".to_string(); + + // pchar = unreserved / pct-encoded / sub-delims / ":" / "@" + safe.push_str(":@"); + + // unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + for c in 'a'..='z' { + safe.push(c); + } + + for c in 'A'..='Z' { + safe.push(c); + } + + for c in '0'..='9' { + safe.push(c); + } + + safe.push_str("-._~"); + + // pct-encoded = "%" HEXDIG HEXDIG + + // sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" + safe.push_str("!$'()*+,;"); + + for c in safe.chars() { + let s = c.to_string(); + assert_eq!(percent_encode_query_param(&s), s); + } + + for c in '\x00'..='\x7F' { + let s = c.to_string(); + if safe.contains(c) { + assert_eq!(percent_encode_query_param(&s), s); + } else { + assert_eq!(percent_encode_query_param(&s), format!("%{:02X}", c as u8)); + } + } + } } From 3cf13293caf7c8b5b87e172710669074dbdb6c24 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 8 Nov 2025 01:37:20 -0800 Subject: [PATCH 2/9] Tweak --- src/magnet_link.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/magnet_link.rs b/src/magnet_link.rs index 6c3a02b..1720252 100644 --- a/src/magnet_link.rs +++ b/src/magnet_link.rs @@ -421,8 +421,8 @@ mod tests { // // https://datatracker.ietf.org/doc/html/rfc3986#section-3.4 // - // `%` is omitted since it is used to introduce percent encoded characters - // `&` and `=` are omitted since they are used to delimit query parameter keys and values + // `&` and `=` are omitted since they are used to delimit query parameter + // keys and values // query = *( pchar / "/" / "?" ) let mut safe = "/?".to_string(); @@ -445,8 +445,6 @@ mod tests { safe.push_str("-._~"); - // pct-encoded = "%" HEXDIG HEXDIG - // sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" safe.push_str("!$'()*+,;"); From 80a1fdbb47b11b865d6dbca1775b4dca7014a720 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 8 Nov 2025 01:37:50 -0800 Subject: [PATCH 3/9] Revise --- src/magnet_link.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/magnet_link.rs b/src/magnet_link.rs index 1720252..39c7f92 100644 --- a/src/magnet_link.rs +++ b/src/magnet_link.rs @@ -458,7 +458,10 @@ mod tests { if safe.contains(c) { assert_eq!(percent_encode_query_param(&s), s); } else { - assert_eq!(percent_encode_query_param(&s), format!("%{:02X}", c as u8)); + assert_eq!( + percent_encode_query_param(&s), + format!("%{:02X}", u8::try_from(c).unwrap()) + ); } } } From c04495ffd682fccced0bddae5a26e32050b76612 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 8 Nov 2025 01:37:55 -0800 Subject: [PATCH 4/9] Reform --- src/magnet_link.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/magnet_link.rs b/src/magnet_link.rs index 39c7f92..b619301 100644 --- a/src/magnet_link.rs +++ b/src/magnet_link.rs @@ -460,7 +460,7 @@ mod tests { } else { assert_eq!( percent_encode_query_param(&s), - format!("%{:02X}", u8::try_from(c).unwrap()) + format!("%{:02X}", u8::try_from(c).unwrap()), ); } } From 6e8a8b81971503c24d411d0eae007a1567429781 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 8 Nov 2025 13:00:00 -0800 Subject: [PATCH 5/9] Adjust --- src/magnet_link.rs | 23 +++++++++++++---------- src/subcommand/torrent/create.rs | 2 +- src/subcommand/torrent/link.rs | 8 ++++---- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/magnet_link.rs b/src/magnet_link.rs index b619301..e9c350a 100644 --- a/src/magnet_link.rs +++ b/src/magnet_link.rs @@ -1,7 +1,7 @@ use crate::common::*; fn percent_encode_query_param(s: &str) -> String { - const QUERY: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS + const ENCODE: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS .add(b' ') .add(b'"') .add(b'#') @@ -18,7 +18,7 @@ fn percent_encode_query_param(s: &str) -> String { .add(b'{') .add(b'|') .add(b'}'); - percent_encoding::utf8_percent_encode(s, QUERY).to_string() + percent_encoding::utf8_percent_encode(s, ENCODE).to_string() } #[derive(Clone, Debug, PartialEq)] @@ -235,7 +235,7 @@ mod tests { link.add_tracker(Url::parse("http://foo.com/announce").unwrap()); assert_eq!( link.to_url().as_str(), - "magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709&tr=http%3A%2F%2Ffoo.com%2Fannounce" + "magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709&tr=http://foo.com/announce" ); } @@ -265,8 +265,8 @@ mod tests { concat!( "magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709", "&dn=foo", - "&tr=http%3A%2F%2Ffoo.com%2Fannounce", - "&tr=http%3A%2F%2Fbar.net%2Fannounce", + "&tr=http://foo.com/announce", + "&tr=http://bar.net/announce", "&x.pe=foo.com:1337", "&x.pe=bar.net:666", ), @@ -293,8 +293,8 @@ mod tests { let magnet_str = concat!( "magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709", "&dn=foo", - "&tr=http%3A%2F%2Ffoo.com%2Fannounce", - "&tr=http%3A%2F%2Fbar.net%2Fannounce" + "&tr=http://foo.com/announce", + "&tr=http://bar.net/announce" ); let link_from = MagnetLink::from_str(magnet_str).unwrap(); @@ -307,7 +307,7 @@ mod tests { let magnet_str = concat!( "magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709", "&dn=foo", - "&tr=http%3A%2F%2Ffoo.com%2Fannounce", + "&tr=http://foo.com/announce", ); let link_from = MagnetLink::from_str(magnet_str).unwrap(); @@ -453,14 +453,17 @@ mod tests { assert_eq!(percent_encode_query_param(&s), s); } - for c in '\x00'..='\x7F' { + for c in '\u{0}'..='\u{80}' { let s = c.to_string(); if safe.contains(c) { assert_eq!(percent_encode_query_param(&s), s); } else { assert_eq!( percent_encode_query_param(&s), - format!("%{:02X}", u8::try_from(c).unwrap()), + s.bytes() + .map(|byte| format!("%{byte:02X}")) + .collect::>() + .join(""), ); } } diff --git a/src/subcommand/torrent/create.rs b/src/subcommand/torrent/create.rs index 97a2a00..fc4bd8f 100644 --- a/src/subcommand/torrent/create.rs +++ b/src/subcommand/torrent/create.rs @@ -2593,7 +2593,7 @@ Content Size 9 bytes "magnet:\ ?xt=urn:btih:516735f4b80f2b5487eed5f226075bdcde33a54e\ &dn=foo\ - &tr=http%3A%2F%2Ffoo.com%2Fannounce\n" + &tr=http://foo.com/announce\n" ); } diff --git a/src/subcommand/torrent/link.rs b/src/subcommand/torrent/link.rs index c89331b..1114fd8 100644 --- a/src/subcommand/torrent/link.rs +++ b/src/subcommand/torrent/link.rs @@ -233,7 +233,7 @@ mod tests { assert_eq!( env.out(), format!( - "magnet:?xt=urn:btih:{}&dn=foo&tr=https%3A%2F%2Ffoo.com%2Fannounce\n", + "magnet:?xt=urn:btih:{}&dn=foo&tr=https://foo.com/announce\n", infohash ), ); @@ -266,7 +266,7 @@ mod tests { assert_eq!( env.out(), format!( - "magnet:?xt=urn:btih:{}&dn=foo&tr=https%3A%2F%2Ffoo.com%2Fannounce&tr=https%3A%2F%2Fbar.com%2Fannounce\n", + "magnet:?xt=urn:btih:{}&dn=foo&tr=https://foo.com/announce&tr=https://bar.com/announce\n", infohash ), ); @@ -300,7 +300,7 @@ mod tests { assert_eq!( env.out(), format!( - "magnet:?xt=urn:btih:{}&dn=foo&tr=https%3A%2F%2Ffoo.com%2Fannounce&x.pe=foo.com:1337\n", + "magnet:?xt=urn:btih:{}&dn=foo&tr=https://foo.com/announce&x.pe=foo.com:1337\n", infohash ), ); @@ -336,7 +336,7 @@ mod tests { assert_eq!( env.out(), format!( - "magnet:?xt=urn:btih:{}&dn=foo&tr=https%3A%2F%2Ffoo.com%2Fannounce&so=2,4,6\n", + "magnet:?xt=urn:btih:{}&dn=foo&tr=https://foo.com/announce&so=2,4,6\n", infohash ), ); From ac1ca81d08daf06e2133f75144c7c585e5f3fe57 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 8 Nov 2025 13:06:19 -0800 Subject: [PATCH 6/9] Adapt --- Cargo.toml | 1 + justfile | 4 ++++ src/magnet_link.rs | 16 +++++++++++----- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4648005..8fa5188 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,7 @@ temptree = "0.2.0" [lints.clippy] all = { level = "deny", priority = -1 } float_cmp = "allow" +format_collect = "allow" ignore_without_reason = "allow" large_enum_variant = "allow" needless_pass_by_value = "allow" diff --git a/justfile b/justfile index bb32fce..0bc9f42 100644 --- a/justfile +++ b/justfile @@ -29,6 +29,10 @@ done BRANCH=`git rev-parse --abbrev-ref HEAD`: git rebase github/master master git branch -d {{BRANCH}} +ci: clippy forbid test + cargo fmt -- --check + cargo test --all -- --ignored + test: cargo test --all diff --git a/src/magnet_link.rs b/src/magnet_link.rs index e9c350a..18987b1 100644 --- a/src/magnet_link.rs +++ b/src/magnet_link.rs @@ -54,7 +54,6 @@ impl MagnetLink { } } - #[allow(dead_code)] pub(crate) fn set_name(&mut self, name: impl Into) { self.name = Some(name.into()); } @@ -80,7 +79,7 @@ impl MagnetLink { query.push('&'); query.push_str(key); query.push('='); - query.push_str(&percent_encode_query_param(&value)); + query.push_str(&percent_encode_query_param(value)); }; if let Some(name) = &self.name { @@ -99,7 +98,7 @@ impl MagnetLink { let indices = self .indices .iter() - .map(|index| index.to_string()) + .map(ToString::to_string) .collect::>() .join(","); append("so", &indices); @@ -414,6 +413,14 @@ mod tests { ); } + #[test] + fn magnet_link_query_params_are_percent_encoded() { + // test: + // - name + // - tracker + // - peer + } + #[test] fn percent_encode() { // Build a string containing all safe characters to test against using the @@ -462,8 +469,7 @@ mod tests { percent_encode_query_param(&s), s.bytes() .map(|byte| format!("%{byte:02X}")) - .collect::>() - .join(""), + .collect::(), ); } } From b51520e0057bfce431d16c946fe4ef9cab71f0dc Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 8 Nov 2025 13:16:09 -0800 Subject: [PATCH 7/9] Revise --- src/magnet_link.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/magnet_link.rs b/src/magnet_link.rs index 18987b1..a3513fc 100644 --- a/src/magnet_link.rs +++ b/src/magnet_link.rs @@ -415,10 +415,23 @@ mod tests { #[test] fn magnet_link_query_params_are_percent_encoded() { - // test: - // - name - // - tracker - // - peer + let mut e = + MagnetLink::from_str(&"magnet:?xt=urn:btih:0000000000000000000000000000000000000000") + .unwrap(); + e.set_name("foo bar"); + e.add_tracker("http://[::]".parse().unwrap()); + e.add_peer("[::]:0".parse().unwrap()); + + assert_eq!( + e.to_url().as_str(), + concat!( + "magnet:", + "?xt=urn:btih:0000000000000000000000000000000000000000", + "&dn=foo%20bar", + "&tr=http://%5B::%5D/", + "&x.pe=%5B::%5D:0", + ), + ); } #[test] From 4824ab22f9da37b529a3d09b6e73e903bdc19323 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 8 Nov 2025 13:19:00 -0800 Subject: [PATCH 8/9] Revise --- src/magnet_link.rs | 53 +++++++++++++++++++++------------------------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/src/magnet_link.rs b/src/magnet_link.rs index a3513fc..34fbe6b 100644 --- a/src/magnet_link.rs +++ b/src/magnet_link.rs @@ -1,26 +1,5 @@ use crate::common::*; -fn percent_encode_query_param(s: &str) -> String { - const ENCODE: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS - .add(b' ') - .add(b'"') - .add(b'#') - .add(b'%') - .add(b'&') - .add(b'<') - .add(b'=') - .add(b'>') - .add(b'[') - .add(b'\\') - .add(b']') - .add(b'^') - .add(b'`') - .add(b'{') - .add(b'|') - .add(b'}'); - percent_encoding::utf8_percent_encode(s, ENCODE).to_string() -} - #[derive(Clone, Debug, PartialEq)] pub(crate) struct MagnetLink { pub(crate) indices: BTreeSet, @@ -79,7 +58,7 @@ impl MagnetLink { query.push('&'); query.push_str(key); query.push('='); - query.push_str(&percent_encode_query_param(value)); + query.push_str(&Self::percent_encode_query_param(value)); }; if let Some(name) = &self.name { @@ -168,6 +147,27 @@ impl MagnetLink { Ok(link) } + + fn percent_encode_query_param(s: &str) -> String { + const ENCODE: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'%') + .add(b'&') + .add(b'<') + .add(b'=') + .add(b'>') + .add(b'[') + .add(b'\\') + .add(b']') + .add(b'^') + .add(b'`') + .add(b'{') + .add(b'|') + .add(b'}'); + percent_encoding::utf8_percent_encode(s, ENCODE).to_string() + } } impl FromStr for MagnetLink { @@ -468,18 +468,13 @@ mod tests { // sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" safe.push_str("!$'()*+,;"); - for c in safe.chars() { - let s = c.to_string(); - assert_eq!(percent_encode_query_param(&s), s); - } - for c in '\u{0}'..='\u{80}' { let s = c.to_string(); if safe.contains(c) { - assert_eq!(percent_encode_query_param(&s), s); + assert_eq!(MagnetLink::percent_encode_query_param(&s), s); } else { assert_eq!( - percent_encode_query_param(&s), + MagnetLink::percent_encode_query_param(&s), s.bytes() .map(|byte| format!("%{byte:02X}")) .collect::(), From 986101a9c79ee706fef4a3fe8434beb3b8742c5c Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sat, 8 Nov 2025 13:20:36 -0800 Subject: [PATCH 9/9] Enhance --- src/magnet_link.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/magnet_link.rs b/src/magnet_link.rs index 34fbe6b..d2db6ea 100644 --- a/src/magnet_link.rs +++ b/src/magnet_link.rs @@ -415,9 +415,9 @@ mod tests { #[test] fn magnet_link_query_params_are_percent_encoded() { - let mut e = - MagnetLink::from_str(&"magnet:?xt=urn:btih:0000000000000000000000000000000000000000") - .unwrap(); + let mut e = "magnet:?xt=urn:btih:0000000000000000000000000000000000000000" + .parse::() + .unwrap(); e.set_name("foo bar"); e.add_tracker("http://[::]".parse().unwrap()); e.add_peer("[::]:0".parse().unwrap());