ravenrs_sheet/engine/
links.rs1use crate::base::RangeRef;
13use serde_json::{Value as Json, json};
14
15use crate::engine::{EngineError, Result, Session, UiRequest};
16
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20pub enum LinkPolicy {
21 #[default]
24 Confirm,
25 Allow,
28}
29
30pub const NEEDS_CONFIRMATION: &str = "Web and e-mail links open only when the user clicks them and confirms; a command can't open them.";
32
33impl Session {
34 pub fn open_confirmed_link(&mut self, target: &str) -> Result<Json> {
39 match classify(target) {
40 LinkKind::Internal(r) => self.execute("edit.goTo", json!({"reference": r.trim_start_matches('#')})),
41 LinkKind::External(u) => {
42 self.ui_requests.push(UiRequest::OpenUrl(u.clone()));
43 Ok(json!({"opened": u}))
44 }
45 LinkKind::Refused(why) => Err(EngineError::Other(why)),
46 }
47 }
48}
49
50#[derive(Clone, Debug, PartialEq, Eq)]
52pub enum LinkKind {
53 Internal(String),
55 External(String),
57 Refused(String),
59}
60
61const ALLOWED: [&str; 3] = ["http", "https", "mailto"];
62
63pub fn classify(target: &str) -> LinkKind {
65 let t = target.trim();
66 if t.chars().any(char::is_control) {
67 return LinkKind::Refused("The link contains control characters.".into());
68 }
69 if t.starts_with("\\\\") || t.starts_with("//") {
70 return LinkKind::Refused("Links to network locations aren't opened.".into());
71 }
72 if RangeRef::parse(t).is_some() {
73 return LinkKind::Internal(t.to_string());
74 }
75 let Some((scheme, rest)) = t.split_once(':') else { return LinkKind::Internal(t.to_string()) };
76 let scheme_like = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
77 && scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
78 if !scheme_like {
79 return LinkKind::Internal(t.to_string());
80 }
81 let lower = scheme.to_ascii_lowercase();
82 if ALLOWED.contains(&lower.as_str()) && !rest.trim().is_empty() {
83 LinkKind::External(t.to_string())
84 } else {
85 LinkKind::Refused(format!("Links of type \"{lower}:\" aren't opened. Only http, https and mailto links are allowed."))
86 }
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 #[test]
94 fn only_http_https_and_mailto_leave_the_workbook() {
95 for ok in ["http://a.example/x", "HTTPS://A.example", " https://a.example ", "MailTo:a@b.example"] {
96 assert!(matches!(classify(ok), LinkKind::External(_)), "{ok}");
97 }
98 assert_eq!(classify(" https://a.example "), LinkKind::External("https://a.example".into()));
99 for bad in [
100 "javascript:alert(1)",
101 "JaVaScRiPt:alert(1)",
102 "file:///etc/passwd",
103 "smb://host/share",
104 "ms-excel:ofe|u|http://x",
105 "\\\\host\\share\\x.exe",
106 "//host/share",
107 "C:\\Windows\\calc.exe",
108 "\tjavascript:1",
109 "java\nscript:1",
110 "\u{1}http://a.example",
111 "http:",
112 "mailto:",
113 "x-custom://a",
114 ] {
115 assert!(matches!(classify(bad), LinkKind::Refused(_)), "{bad:?} -> {:?}", classify(bad));
116 }
117 }
118
119 #[test]
120 fn references_stay_internal() {
121 for r in ["Sheet2!A1", "'My Sheet'!B2:C3", "A1:B2", "#Sheet1!A1", "Total", "", " "] {
122 assert!(matches!(classify(r), LinkKind::Internal(_)), "{r:?} -> {:?}", classify(r));
123 }
124 }
125}