1use std::fmt;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Refused {
15 Scheme,
18 Character,
21 Malformed,
23 NoHost,
25 Credentials,
27}
28
29impl fmt::Display for Refused {
30 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31 f.write_str(match self {
32 Refused::Scheme => "not an http or https link",
33 Refused::Character => "a character a link cannot hold",
34 Refused::Malformed => "not a URL",
35 Refused::NoHost => "no host",
36 Refused::Credentials => "a user name or password",
37 })
38 }
39}
40
41pub fn checked_url(raw: &str) -> Result<String, Refused> {
45 if raw
46 .chars()
47 .any(|c| c.is_control() || c.is_whitespace() || c == '\\' || bidi(c))
48 {
49 return Err(Refused::Character);
50 }
51 let Some(rest) = raw
55 .strip_prefix("https://")
56 .or_else(|| raw.strip_prefix("http://"))
57 else {
58 return Err(Refused::Scheme);
59 };
60 if rest.starts_with('/') {
61 return Err(Refused::Malformed);
62 }
63 let url = url::Url::parse(raw).map_err(|_| Refused::Malformed)?;
64 if !matches!(url.scheme(), "http" | "https") {
65 return Err(Refused::Scheme);
66 }
67 if url.host_str().is_none_or(str::is_empty) {
68 return Err(Refused::NoHost);
69 }
70 if !url.username().is_empty() || url.password().is_some() {
71 return Err(Refused::Credentials);
72 }
73 let out = url.as_str().to_string();
74 assert!(
76 !out.starts_with('-'),
77 "a checked URL starts with its scheme"
78 );
79 Ok(out)
80}
81
82fn bidi(c: char) -> bool {
85 matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum Platform {
91 Windows,
92 MacOs,
93 Unix,
95}
96
97impl Platform {
98 pub fn current() -> Self {
99 if cfg!(windows) {
100 Platform::Windows
101 } else if cfg!(target_os = "macos") {
102 Platform::MacOs
103 } else {
104 Platform::Unix
105 }
106 }
107}
108
109pub fn local_desktop(platform: Platform, env: impl Fn(&str) -> Option<String>) -> bool {
113 let set = |name: &str| env(name).is_some_and(|v| !v.trim().is_empty());
114 if set("SSH_CONNECTION") || set("SSH_TTY") {
115 return false;
116 }
117 match platform {
118 Platform::Windows | Platform::MacOs => true,
119 Platform::Unix => set("DISPLAY") || set("WAYLAND_DISPLAY"),
120 }
121}
122
123pub fn opener_argv(platform: Platform, url: &str) -> Vec<String> {
127 let program: &[&str] = match platform {
128 Platform::Windows => &["rundll32", "url.dll,FileProtocolHandler"],
129 Platform::MacOs => &["open"],
130 Platform::Unix => &["xdg-open"],
131 };
132 program
133 .iter()
134 .map(|s| s.to_string())
135 .chain(std::iter::once(url.to_string()))
136 .collect()
137}
138
139pub fn open(url: &str) -> std::io::Result<()> {
141 crate::external_open::start(&opener_argv(Platform::current(), url))
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn only_plain_http_and_https_links_pass() {
150 let accepted = [
151 ("https://example.com", "https://example.com/"),
152 ("http://example.org/x", "http://example.org/x"),
153 (
154 "https://example.com/a?b=1&c=^2#top",
155 "https://example.com/a?b=1&c=^2#top",
156 ),
157 ("https://Example.COM/Path", "https://example.com/Path"),
158 ("https://example.com:8443/", "https://example.com:8443/"),
159 ("http://127.0.0.1/", "http://127.0.0.1/"),
160 ("https://[::1]/x", "https://[::1]/x"),
161 (
163 "https://example.com/a\"b<c>",
164 "https://example.com/a%22b%3Cc%3E",
165 ),
166 ("https://example.com/ü", "https://example.com/%C3%BC"),
167 ("https://bücher.example/", "https://xn--bcher-kva.example/"),
169 ("https://例え.jp/", "https://xn--r8jz45g.jp/"),
170 ];
171 for (raw, out) in accepted {
172 assert_eq!(checked_url(raw).as_deref(), Ok(out), "{raw}");
173 }
174 let refused = [
175 ("file:///etc/passwd", Refused::Scheme),
176 ("javascript:alert(1)", Refused::Scheme),
177 ("ms-msdt:/id PCWDiagnostic", Refused::Character),
178 ("ms-msdt:/id", Refused::Scheme),
179 ("search-ms:query=x", Refused::Scheme),
180 ("datui-custom://x", Refused::Scheme),
181 ("ftp://example.com/", Refused::Scheme),
182 ("s3://bucket/key", Refused::Scheme),
183 ("HTTPS://example.com", Refused::Scheme),
184 ("Https://example.com", Refused::Scheme),
185 ("hTTp://example.com", Refused::Scheme),
186 ("https:example.com", Refused::Scheme),
187 ("https:/example.com", Refused::Scheme),
188 ("//example.com", Refused::Scheme),
189 ("example.com", Refused::Scheme),
190 ("-https://example.com", Refused::Scheme),
191 (" https://example.com", Refused::Character),
192 ("https://example.com ", Refused::Character),
193 ("https://exa mple.com", Refused::Character),
194 ("https://example.com/\n", Refused::Character),
195 ("https://example.com/\tx", Refused::Character),
196 ("https://example.com/\u{7f}", Refused::Character),
197 ("https://example.com/\u{202e}gpj.exe", Refused::Character),
198 ("https://example.com/\u{2066}x", Refused::Character),
199 ("https:\\\\example.com", Refused::Character),
200 ("https://example.com\\@evil.com", Refused::Character),
201 ("https://user@example.com", Refused::Credentials),
202 ("https://user:pw@example.com", Refused::Credentials),
203 ("https://:pw@example.com", Refused::Credentials),
204 ("https://", Refused::Malformed),
205 ("https:///path", Refused::Malformed),
206 ("https://exa%mple.com", Refused::Malformed),
207 ];
208 for (raw, why) in refused {
209 assert_eq!(checked_url(raw), Err(why), "{raw:?}");
210 }
211 }
212
213 fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
214 let pairs: Vec<(String, String)> = pairs
215 .iter()
216 .map(|(k, v)| (k.to_string(), v.to_string()))
217 .collect();
218 move |name| {
219 pairs
220 .iter()
221 .find(|(k, _)| k == name)
222 .map(|(_, v)| v.clone())
223 }
224 }
225
226 type Case = (Platform, &'static [(&'static str, &'static str)], bool);
228
229 #[test]
230 fn a_desktop_is_local_without_ssh_and_with_a_display() {
231 use Platform::*;
232 let cases: &[Case] = &[
233 (Unix, &[("DISPLAY", ":0")], true),
234 (Unix, &[("WAYLAND_DISPLAY", "wayland-1")], true),
235 (Unix, &[], false),
236 (Unix, &[("DISPLAY", " ")], false),
237 (
239 Unix,
240 &[("DISPLAY", "localhost:10.0"), ("SSH_CONNECTION", "a b c d")],
241 false,
242 ),
243 (
244 Unix,
245 &[("WAYLAND_DISPLAY", "w"), ("SSH_TTY", "/dev/pts/1")],
246 false,
247 ),
248 (MacOs, &[], true),
249 (MacOs, &[("SSH_CONNECTION", "a b c d")], false),
250 (Windows, &[], true),
251 (Windows, &[("SSH_TTY", "x")], false),
252 ];
253 for (platform, vars, local) in cases {
254 assert_eq!(
255 local_desktop(*platform, env(vars)),
256 *local,
257 "{platform:?} {vars:?}"
258 );
259 }
260 }
261
262 #[test]
264 fn the_opener_is_a_program_with_the_url_as_one_argument() {
265 let url = checked_url("https://example.com/a?b=1&c=^2").unwrap();
266 assert_eq!(
267 opener_argv(Platform::Windows, &url),
268 ["rundll32", "url.dll,FileProtocolHandler", url.as_str()]
269 );
270 assert_eq!(opener_argv(Platform::MacOs, &url), ["open", url.as_str()]);
271 assert_eq!(
272 opener_argv(Platform::Unix, &url),
273 ["xdg-open", url.as_str()]
274 );
275 for platform in [Platform::Windows, Platform::MacOs, Platform::Unix] {
276 let argv = opener_argv(platform, &url);
277 for shell in ["cmd", "sh", "bash", "powershell", "/c", "-c", "start"] {
278 assert!(!argv.iter().any(|a| a == shell), "{platform:?}: {argv:?}");
279 }
280 }
281 }
282}