lightning_liquidity/lsps5/
url_utils.rs1use super::msgs::LSPS5ProtocolError;
13
14use lightning::ln::msgs::DecodeError;
15use lightning::util::ser::{Readable, Writeable};
16use lightning_types::string::UntrustedString;
17
18use alloc::string::String;
19
20#[derive(Debug, Clone, PartialEq, Eq, Hash)]
22pub struct LSPSUrl(UntrustedString);
23
24impl LSPSUrl {
25 pub fn parse(url_str: String) -> Result<Self, LSPS5ProtocolError> {
33 if url_str.chars().any(|c| !Self::is_valid_url_char(c)) {
34 return Err(LSPS5ProtocolError::UrlParse);
35 }
36
37 let (scheme, remainder) =
38 url_str.split_once("://").ok_or_else(|| LSPS5ProtocolError::UrlParse)?;
39
40 if !scheme.eq_ignore_ascii_case("https") {
41 return Err(LSPS5ProtocolError::UnsupportedProtocol);
42 }
43
44 let host_section =
45 remainder.split(['/', '?', '#']).next().ok_or_else(|| LSPS5ProtocolError::UrlParse)?;
46
47 let host_without_auth = host_section
48 .split('@')
49 .next_back()
50 .filter(|s| !s.is_empty())
51 .ok_or_else(|| LSPS5ProtocolError::UrlParse)?;
52
53 if host_without_auth.is_empty()
54 || host_without_auth.chars().any(|c| !Self::is_valid_host_char(c))
55 {
56 return Err(LSPS5ProtocolError::UrlParse);
57 }
58
59 match host_without_auth.rsplit_once(':') {
60 Some((hostname, _)) if hostname.is_empty() => return Err(LSPS5ProtocolError::UrlParse),
61 Some((_, port)) => {
62 if !port.is_empty() && port.parse::<u16>().is_err() {
63 return Err(LSPS5ProtocolError::UrlParse);
64 }
65 },
66 None => {},
67 };
68
69 Ok(LSPSUrl(UntrustedString(url_str)))
70 }
71
72 pub fn url_length(&self) -> usize {
74 self.0 .0.chars().count()
75 }
76
77 pub fn url(&self) -> &str {
79 self.0 .0.as_str()
80 }
81
82 fn is_valid_url_char(c: char) -> bool {
83 c.is_ascii_alphanumeric()
84 || matches!(c, ':' | '/' | '.' | '@' | '?' | '#' | '%' | '-' | '_' | '&' | '=')
85 }
86
87 fn is_valid_host_char(c: char) -> bool {
88 c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '_')
89 }
90}
91
92impl Writeable for LSPSUrl {
93 fn write<W: lightning::util::ser::Writer>(
94 &self, writer: &mut W,
95 ) -> Result<(), lightning::io::Error> {
96 self.0.write(writer)
97 }
98}
99
100impl Readable for LSPSUrl {
101 fn read<R: lightning::io::Read>(reader: &mut R) -> Result<Self, DecodeError> {
102 Ok(Self(Readable::read(reader)?))
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use crate::alloc::string::ToString;
110 use alloc::vec::Vec;
111 use proptest::prelude::*;
112
113 #[test]
114 fn test_extremely_long_url() {
115 let url_str = format!("https://{}/path", "a".repeat(1000)).to_string();
116 let url_chars = url_str.chars().count();
117 let result = LSPSUrl::parse(url_str);
118
119 assert!(result.is_ok());
120 let url = result.unwrap();
121 assert_eq!(url.0 .0.chars().count(), url_chars);
122 }
123
124 #[test]
125 fn test_parse_http_url() {
126 let url_str = "http://example.com/path".to_string();
127 let url = LSPSUrl::parse(url_str).unwrap_err();
128 assert_eq!(url, LSPS5ProtocolError::UnsupportedProtocol);
129 }
130
131 #[test]
132 fn valid_lsps_url() {
133 let test_vec: Vec<&'static str> = vec![
134 "https://www.example.org/push?l=1234567890abcopqrstuv&c=best",
135 "https://www.example.com/path",
136 "https://example.org",
137 "https://example.com:8080/path",
138 "https://api.example.com/v1/resources",
139 "https://example.com/page#section1",
140 "https://example.com/search?q=test#results",
141 "https://user:pass@example.com/",
142 "https://192.168.1.1/admin",
143 "https://example.com://path",
144 "https://example.com/path%20with%20spaces",
145 "https://example_example.com/path?query=with&spaces=true",
146 ];
147 for url_str in test_vec {
148 let url = LSPSUrl::parse(url_str.to_string());
149 assert!(url.is_ok(), "Failed to parse URL: {}", url_str);
150 }
151 }
152
153 #[test]
154 fn invalid_lsps_url() {
155 let test_vec = vec![
156 "ftp://ftp.example.org/pub/files/document.pdf",
157 "sftp://user:password@sftp.example.com:22/uploads/",
158 "ssh://username@host.com:2222",
159 "lightning://03a.example.com/invoice?amount=10000",
160 "ftp://user@ftp.example.com/files/",
161 "https://例子.测试/path",
162 "a123+-.://example.com",
163 "a123+-.://example.com",
164 "https:\\\\example.com\\path",
165 "https:///whatever",
166 "https://example.com/path with spaces",
167 ];
168 for url_str in test_vec {
169 let url = LSPSUrl::parse(url_str.to_string());
170 assert!(url.is_err(), "Expected error for URL: {}", url_str);
171 }
172 }
173
174 #[test]
175 fn parsing_errors() {
176 let test_vec = vec![
177 "example.com/path",
178 "https://bad domain.com/",
179 "https://example.com\0/path",
180 "https://",
181 "ht@ps://example.com",
182 "http!://example.com",
183 "1https://example.com",
184 "https://://example.com",
185 "https://example.com:port/path",
186 "https://:8080/path",
187 "https:",
188 "://",
189 "https://example.com\0/path",
190 ];
191 for url_str in test_vec {
192 let url = LSPSUrl::parse(url_str.to_string());
193 assert!(url.is_err(), "Expected error for URL: {}", url_str);
194 }
195 }
196
197 fn host_strategy() -> impl Strategy<Value = String> {
198 prop_oneof![
199 proptest::string::string_regex(
200 "[a-z0-9]+(?:-[a-z0-9]+)*(?:\\.[a-z0-9]+(?:-[a-z0-9]+)*)*"
201 )
202 .unwrap(),
203 (0u8..=255u8, 0u8..=255u8, 0u8..=255u8, 0u8..=255u8)
204 .prop_map(|(a, b, c, d)| format!("{}.{}.{}.{}", a, b, c, d))
205 ]
206 }
207
208 proptest! {
209 #[test]
210 fn proptest_parse_round_trip(
211 host in host_strategy(),
212 port in proptest::option::of(0u16..=65535u16),
213 path in proptest::option::of(proptest::string::string_regex("[a-zA-Z0-9._%&=:@/-]{0,20}").unwrap()),
214 query in proptest::option::of(proptest::string::string_regex("[a-zA-Z0-9._%&=:@/-]{0,20}").unwrap()),
215 fragment in proptest::option::of(proptest::string::string_regex("[a-zA-Z0-9._%&=:@/-]{0,20}").unwrap())
216 ) {
217 let mut url = format!("https://{}", host);
218 if let Some(p) = port {
219 url.push_str(&format!(":{}", p));
220 }
221 if let Some(pth) = &path {
222 url.push('/');
223 url.push_str(pth);
224 }
225 if let Some(q) = &query {
226 url.push('?');
227 url.push_str(q);
228 }
229 if let Some(f) = &fragment {
230 url.push('#');
231 url.push_str(f);
232 }
233
234 let parsed = LSPSUrl::parse(url.clone()).expect("should parse");
235 prop_assert_eq!(parsed.url(), url.as_str());
236 prop_assert_eq!(parsed.url_length(), url.chars().count());
237 }
238 }
239}