use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Refused {
Scheme,
Character,
Malformed,
NoHost,
Credentials,
}
impl fmt::Display for Refused {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Refused::Scheme => "not an http or https link",
Refused::Character => "a character a link cannot hold",
Refused::Malformed => "not a URL",
Refused::NoHost => "no host",
Refused::Credentials => "a user name or password",
})
}
}
pub fn checked_url(raw: &str) -> Result<String, Refused> {
if raw
.chars()
.any(|c| c.is_control() || c.is_whitespace() || c == '\\' || bidi(c))
{
return Err(Refused::Character);
}
let Some(rest) = raw
.strip_prefix("https://")
.or_else(|| raw.strip_prefix("http://"))
else {
return Err(Refused::Scheme);
};
if rest.starts_with('/') {
return Err(Refused::Malformed);
}
let url = url::Url::parse(raw).map_err(|_| Refused::Malformed)?;
if !matches!(url.scheme(), "http" | "https") {
return Err(Refused::Scheme);
}
if url.host_str().is_none_or(str::is_empty) {
return Err(Refused::NoHost);
}
if !url.username().is_empty() || url.password().is_some() {
return Err(Refused::Credentials);
}
let out = url.as_str().to_string();
assert!(
!out.starts_with('-'),
"a checked URL starts with its scheme"
);
Ok(out)
}
fn bidi(c: char) -> bool {
matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
Windows,
MacOs,
Unix,
}
impl Platform {
pub fn current() -> Self {
if cfg!(windows) {
Platform::Windows
} else if cfg!(target_os = "macos") {
Platform::MacOs
} else {
Platform::Unix
}
}
}
pub fn local_desktop(platform: Platform, env: impl Fn(&str) -> Option<String>) -> bool {
let set = |name: &str| env(name).is_some_and(|v| !v.trim().is_empty());
if set("SSH_CONNECTION") || set("SSH_TTY") {
return false;
}
match platform {
Platform::Windows | Platform::MacOs => true,
Platform::Unix => set("DISPLAY") || set("WAYLAND_DISPLAY"),
}
}
pub fn opener_argv(platform: Platform, url: &str) -> Vec<String> {
let program: &[&str] = match platform {
Platform::Windows => &["rundll32", "url.dll,FileProtocolHandler"],
Platform::MacOs => &["open"],
Platform::Unix => &["xdg-open"],
};
program
.iter()
.map(|s| s.to_string())
.chain(std::iter::once(url.to_string()))
.collect()
}
pub fn open(url: &str) -> std::io::Result<()> {
crate::external_open::start(&opener_argv(Platform::current(), url))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_plain_http_and_https_links_pass() {
let accepted = [
("https://example.com", "https://example.com/"),
("http://example.org/x", "http://example.org/x"),
(
"https://example.com/a?b=1&c=^2#top",
"https://example.com/a?b=1&c=^2#top",
),
("https://Example.COM/Path", "https://example.com/Path"),
("https://example.com:8443/", "https://example.com:8443/"),
("http://127.0.0.1/", "http://127.0.0.1/"),
("https://[::1]/x", "https://[::1]/x"),
(
"https://example.com/a\"b<c>",
"https://example.com/a%22b%3Cc%3E",
),
("https://example.com/ü", "https://example.com/%C3%BC"),
("https://bücher.example/", "https://xn--bcher-kva.example/"),
("https://例え.jp/", "https://xn--r8jz45g.jp/"),
];
for (raw, out) in accepted {
assert_eq!(checked_url(raw).as_deref(), Ok(out), "{raw}");
}
let refused = [
("file:///etc/passwd", Refused::Scheme),
("javascript:alert(1)", Refused::Scheme),
("ms-msdt:/id PCWDiagnostic", Refused::Character),
("ms-msdt:/id", Refused::Scheme),
("search-ms:query=x", Refused::Scheme),
("datui-custom://x", Refused::Scheme),
("ftp://example.com/", Refused::Scheme),
("s3://bucket/key", Refused::Scheme),
("HTTPS://example.com", Refused::Scheme),
("Https://example.com", Refused::Scheme),
("hTTp://example.com", Refused::Scheme),
("https:example.com", Refused::Scheme),
("https:/example.com", Refused::Scheme),
("//example.com", Refused::Scheme),
("example.com", Refused::Scheme),
("-https://example.com", Refused::Scheme),
(" https://example.com", Refused::Character),
("https://example.com ", Refused::Character),
("https://exa mple.com", Refused::Character),
("https://example.com/\n", Refused::Character),
("https://example.com/\tx", Refused::Character),
("https://example.com/\u{7f}", Refused::Character),
("https://example.com/\u{202e}gpj.exe", Refused::Character),
("https://example.com/\u{2066}x", Refused::Character),
("https:\\\\example.com", Refused::Character),
("https://example.com\\@evil.com", Refused::Character),
("https://user@example.com", Refused::Credentials),
("https://user:pw@example.com", Refused::Credentials),
("https://:pw@example.com", Refused::Credentials),
("https://", Refused::Malformed),
("https:///path", Refused::Malformed),
("https://exa%mple.com", Refused::Malformed),
];
for (raw, why) in refused {
assert_eq!(checked_url(raw), Err(why), "{raw:?}");
}
}
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let pairs: Vec<(String, String)> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
move |name| {
pairs
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.clone())
}
}
type Case = (Platform, &'static [(&'static str, &'static str)], bool);
#[test]
fn a_desktop_is_local_without_ssh_and_with_a_display() {
use Platform::*;
let cases: &[Case] = &[
(Unix, &[("DISPLAY", ":0")], true),
(Unix, &[("WAYLAND_DISPLAY", "wayland-1")], true),
(Unix, &[], false),
(Unix, &[("DISPLAY", " ")], false),
(
Unix,
&[("DISPLAY", "localhost:10.0"), ("SSH_CONNECTION", "a b c d")],
false,
),
(
Unix,
&[("WAYLAND_DISPLAY", "w"), ("SSH_TTY", "/dev/pts/1")],
false,
),
(MacOs, &[], true),
(MacOs, &[("SSH_CONNECTION", "a b c d")], false),
(Windows, &[], true),
(Windows, &[("SSH_TTY", "x")], false),
];
for (platform, vars, local) in cases {
assert_eq!(
local_desktop(*platform, env(vars)),
*local,
"{platform:?} {vars:?}"
);
}
}
#[test]
fn the_opener_is_a_program_with_the_url_as_one_argument() {
let url = checked_url("https://example.com/a?b=1&c=^2").unwrap();
assert_eq!(
opener_argv(Platform::Windows, &url),
["rundll32", "url.dll,FileProtocolHandler", url.as_str()]
);
assert_eq!(opener_argv(Platform::MacOs, &url), ["open", url.as_str()]);
assert_eq!(
opener_argv(Platform::Unix, &url),
["xdg-open", url.as_str()]
);
for platform in [Platform::Windows, Platform::MacOs, Platform::Unix] {
let argv = opener_argv(platform, &url);
for shell in ["cmd", "sh", "bash", "powershell", "/c", "-c", "start"] {
assert!(!argv.iter().any(|a| a == shell), "{platform:?}: {argv:?}");
}
}
}
}