mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-25 11:16:49 -06:00
84 lines
3.2 KiB
Rust
84 lines
3.2 KiB
Rust
//! Which hyperlink targets may be opened. A cell's link comes from the document (often a file
|
|
//! somebody sent), so it is hostile input: only `http`, `https` and `mailto` ever leave the
|
|
//! workbook for the system; `file:`, `smb:`, UNC paths, custom URL handlers, `javascript:` and
|
|
//! the like are refused. Everything without a scheme stays an in-workbook reference.
|
|
|
|
use gridcraft_core::RangeRef;
|
|
|
|
/// What a link target is.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum LinkKind {
|
|
/// A cell, range, name or table of the workbook (`Sheet2!A1`, `Total`).
|
|
Internal(String),
|
|
/// An address to hand to the system, trimmed: `http`, `https` or `mailto` only.
|
|
External(String),
|
|
/// Refused, with the reason to show.
|
|
Refused(String),
|
|
}
|
|
|
|
const ALLOWED: [&str; 3] = ["http", "https", "mailto"];
|
|
|
|
/// Classifies a link target. Never panics; the single place that decides what may be opened.
|
|
pub fn classify(target: &str) -> LinkKind {
|
|
let t = target.trim();
|
|
if t.chars().any(char::is_control) {
|
|
return LinkKind::Refused("The link contains control characters.".into());
|
|
}
|
|
if t.starts_with("\\\\") || t.starts_with("//") {
|
|
return LinkKind::Refused("Links to network locations aren't opened.".into());
|
|
}
|
|
if RangeRef::parse(t).is_some() {
|
|
return LinkKind::Internal(t.to_string());
|
|
}
|
|
let Some((scheme, rest)) = t.split_once(':') else { return LinkKind::Internal(t.to_string()) };
|
|
let scheme_like = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
|
|
&& scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
|
|
if !scheme_like {
|
|
return LinkKind::Internal(t.to_string());
|
|
}
|
|
let lower = scheme.to_ascii_lowercase();
|
|
if ALLOWED.contains(&lower.as_str()) && !rest.trim().is_empty() {
|
|
LinkKind::External(t.to_string())
|
|
} else {
|
|
LinkKind::Refused(format!("Links of type \"{lower}:\" aren't opened. Only http, https and mailto links are allowed."))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn only_http_https_and_mailto_leave_the_workbook() {
|
|
for ok in ["http://a.example/x", "HTTPS://A.example", " https://a.example ", "MailTo:a@b.example"] {
|
|
assert!(matches!(classify(ok), LinkKind::External(_)), "{ok}");
|
|
}
|
|
assert_eq!(classify(" https://a.example "), LinkKind::External("https://a.example".into()));
|
|
for bad in [
|
|
"javascript:alert(1)",
|
|
"JaVaScRiPt:alert(1)",
|
|
"file:///etc/passwd",
|
|
"smb://host/share",
|
|
"ms-excel:ofe|u|http://x",
|
|
"\\\\host\\share\\x.exe",
|
|
"//host/share",
|
|
"C:\\Windows\\calc.exe",
|
|
"\tjavascript:1",
|
|
"java\nscript:1",
|
|
"\u{1}http://a.example",
|
|
"http:",
|
|
"mailto:",
|
|
"x-custom://a",
|
|
] {
|
|
assert!(matches!(classify(bad), LinkKind::Refused(_)), "{bad:?} -> {:?}", classify(bad));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn references_stay_internal() {
|
|
for r in ["Sheet2!A1", "'My Sheet'!B2:C3", "A1:B2", "#Sheet1!A1", "Total", "", " "] {
|
|
assert!(matches!(classify(r), LinkKind::Internal(_)), "{r:?} -> {:?}", classify(r));
|
|
}
|
|
}
|
|
}
|