Skip to main content

datui_lib/
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::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            // Encoded where a URL must be, so the opener sees one plain token.
162            (
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            // A host that is not ASCII goes out in punycode, as confirmed.
168            ("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    /// A platform, the variables set, and whether that is a local desktop.
227    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            // `ssh -X` sets DISPLAY; the browser would open on the far side.
238            (
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    /// The URL is one argument of a program; nothing is a shell.
263    #[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}