Skip to main content

datui_lib/app/
link_open.rs

1//! A documentation link handed to the system's browser: `o` in the Documentation
2//! view.
3//!
4//! Catalogs and format specs can come from anyone, so a link's text is hostile until
5//! checked. Only `http` and `https` reach the opener: other schemes are where a
6//! link-opening bug turns into running code, through whatever handler the system
7//! has registered for them. The opener gets the parser's serialization of the URL,
8//! never the text as written, as one argument of a program run without a shell.
9
10use std::fmt;
11
12/// Why a link is not opened.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Refused {
15    /// Not written `http://` or `https://`, in lowercase: another scheme, or a form
16    /// a parser reads leniently (`HTTPS:`, `https:x`, `https:\\x`).
17    Scheme,
18    /// A control character, whitespace or a backslash, which parsers drop or turn
19    /// into `/` rather than refuse.
20    Character,
21    /// It does not parse as a URL.
22    Malformed,
23    /// No host to go to.
24    NoHost,
25    /// A user name or password before the host.
26    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
41/// The URL to hand the browser for `raw`, as the parser writes it back: a host that
42/// is not ASCII in punycode, the path percent-encoded. Refused unless it is an
43/// `http` or `https` URL with a host and nothing a lenient parse would mend.
44pub 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    // The scheme as written, not as parsed: the parser lowercases `HTTPS:`, reads
52    // `https:x` as `https://x/` and skips a third slash, and none of those is a
53    // link anyone wrote on purpose.
54    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    // A scheme leads it, so no opener can read it as an option.
75    assert!(
76        !out.starts_with('-'),
77        "a checked URL starts with its scheme"
78    );
79    Ok(out)
80}
81
82/// A character that reorders the text around it, so the URL confirmed would not
83/// read as the URL opened.
84fn bidi(c: char) -> bool {
85    matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
86}
87
88/// The systems whose openers differ.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum Platform {
91    Windows,
92    MacOs,
93    /// Linux and the BSDs: `xdg-open`, and a desktop only where a display is named.
94    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
109/// Whether a browser opened here would open in front of the user: not over SSH
110/// (checked first, since `ssh -X` sets `DISPLAY`), and on Linux and the BSDs only
111/// with a display. `env` looks up a variable; blank counts as unset.
112pub 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
123/// The program and arguments that open `url`, the URL one argument of its own and
124/// no shell anywhere. Windows uses `rundll32 url.dll,FileProtocolHandler`, not
125/// `cmd /c start`, because cmd reads `&` and `^` in a URL as its own syntax.
126pub 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
139/// Start the browser on `url`, already checked, without waiting for it.
140pub fn open(url: &str) -> std::io::Result<()> {
141    crate::inspector::external_open::start(&opener_argv(Platform::current(), url))
142}
143
144#[cfg(test)]
145mod tests;