Skip to main content

odox_ui/
links.rs

1//! Following a hyperlink in a document.
2//!
3//! A document is not trusted with the machine: a link may name any scheme the
4//! desktop has a handler for, and some handlers run programs. Only the three a
5//! person means by a link are opened, in whatever the desktop uses for them,
6//! and nothing else is, whatever the document says.
7//
8// Author: David M. Anderson
9// Built with AI assistance (Claude, Anthropic)
10
11use eframe::egui::Context;
12
13/// Where a link goes, as far as the window is concerned.
14#[derive(Debug, PartialEq, Eq)]
15pub enum Target<'a> {
16    /// A web page or a mail address, handed to the desktop.
17    External(&'a str),
18    /// A place in this document: a bookmark, or a heading as `Name|outline`.
19    Within(&'a str),
20}
21
22/// What a link's `xlink:href` means here, or `None` for one that is not
23/// followed.
24pub fn target(href: &str) -> Option<Target<'_>> {
25    if let Some(place) = href.strip_prefix('#') {
26        return (!place.is_empty()).then_some(Target::Within(place));
27    }
28    let scheme = href.split_once(':')?.0;
29    ["http", "https", "mailto"]
30        .iter()
31        .any(|allowed| scheme.eq_ignore_ascii_case(allowed))
32        .then_some(Target::External(href))
33}
34
35/// Hand an address to the desktop.
36pub fn open(ctx: &Context, address: &str) {
37    ctx.open_url(eframe::egui::OpenUrl::new_tab(address));
38}
39
40#[cfg(test)]
41mod tests {
42    use super::*;
43
44    #[test]
45    fn the_web_and_mail_are_followed() {
46        assert_eq!(
47            target("https://example.com/a"),
48            Some(Target::External("https://example.com/a"))
49        );
50        assert_eq!(
51            target("HTTP://example.com"),
52            Some(Target::External("HTTP://example.com"))
53        );
54        assert_eq!(
55            target("mailto:me@example.com"),
56            Some(Target::External("mailto:me@example.com"))
57        );
58    }
59
60    #[test]
61    fn a_place_in_the_document_is_named_without_its_hash() {
62        assert_eq!(
63            target("#Chapter|outline"),
64            Some(Target::Within("Chapter|outline"))
65        );
66        assert_eq!(target("#"), None);
67    }
68
69    #[test]
70    fn nothing_else_is_followed() {
71        for href in [
72            "file:///etc/passwd",
73            "ms-msdt:/id PCWDiagnostic",
74            "javascript:alert(1)",
75            "smb://host/share",
76            "./other.odt",
77            "example.com",
78            "",
79        ] {
80            assert_eq!(target(href), None, "{href}");
81        }
82    }
83}