lightning_liquidity/lsps5/
url_utils.rs

1// This file is Copyright its original authors, visible in version control
2// history.
3//
4// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE
5// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
6// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option.
7// You may not use this file except in accordance with one or both of these
8// licenses.
9
10//! URL utilities for LSPS5 webhook notifications.
11
12use 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/// Represents a parsed URL for LSPS5 webhook notifications.
21#[derive(Debug, Clone, PartialEq, Eq, Hash)]
22pub struct LSPSUrl(UntrustedString);
23
24impl LSPSUrl {
25	/// Parses a URL string into a URL instance.
26	///
27	/// # Arguments
28	/// * `url_str` - The URL string to parse
29	///
30	/// # Returns
31	/// A Result containing either the parsed URL or an error message.
32	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	/// Returns URL length.
73	pub fn url_length(&self) -> usize {
74		self.0 .0.chars().count()
75	}
76
77	/// Returns the full URL string.
78	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}