#[derive(Clone, Debug)]
pub(crate) struct LinkClick {
pub button: i16,
pub has_modifier_key: bool,
pub has_foreign_target: bool,
pub has_download_attribute: bool,
pub has_no_morph_attribute: bool,
pub rel_tokens: Vec<String>,
pub href_origin: Option<String>,
pub document_origin: String,
pub href_scheme: Option<String>,
pub is_pure_fragment: bool,
pub has_no_href: bool,
}
const NON_DOCUMENT_SCHEMES: [&str; 6] = ["mailto", "tel", "sms", "javascript", "blob", "data"];
pub(crate) fn should_intercept(link: &LinkClick) -> bool {
if link.button != 0 || link.has_modifier_key {
return false;
}
if link.has_no_morph_attribute
|| link.has_foreign_target
|| link.has_download_attribute
|| link.has_no_href
{
return false;
}
if link
.rel_tokens
.iter()
.any(|token| token == "external")
{
return false;
}
let scheme = match &link.href_scheme {
Some(scheme) => scheme,
None => return false,
};
if scheme != "http" && scheme != "https" {
return false;
}
if NON_DOCUMENT_SCHEMES.contains(&scheme.as_str()) {
return false;
}
let origin = match &link.href_origin {
Some(origin) => origin,
None => return false,
};
if origin != &link.document_origin {
return false;
}
if link.is_pure_fragment {
return false;
}
true
}
#[cfg(test)]
mod tests {
use super::{should_intercept, LinkClick};
fn navigable_link() -> LinkClick {
LinkClick {
button: 0,
has_modifier_key: false,
has_foreign_target: false,
has_download_attribute: false,
has_no_morph_attribute: false,
rel_tokens: Vec::new(),
href_origin: Some("https://example.com".to_owned()),
document_origin: "https://example.com".to_owned(),
href_scheme: Some("https".to_owned()),
is_pure_fragment: false,
has_no_href: false,
}
}
#[test]
fn plain_same_origin_left_click_is_intercepted() {
assert!(should_intercept(&navigable_link()));
}
#[test]
fn each_opt_out_falls_through_to_the_browser() {
type OptOutCase = (&'static str, fn(&mut LinkClick));
let cases: Vec<OptOutCase> = vec![
("middle-button click", |link| link.button = 1),
("right-button click", |link| link.button = 2),
("modifier key held (cmd/ctrl/shift/alt)", |link| {
link.has_modifier_key = true
}),
("data-no-morph attribute", |link| {
link.has_no_morph_attribute = true
}),
("target=_blank (foreign target)", |link| {
link.has_foreign_target = true
}),
("download attribute", |link| {
link.has_download_attribute = true
}),
("missing href", |link| link.has_no_href = true),
("rel=external", |link| {
link.rel_tokens = vec!["external".to_owned()]
}),
("rel list containing external", |link| {
link.rel_tokens = vec!["noopener".to_owned(), "external".to_owned()]
}),
("mailto: scheme", |link| {
link.href_scheme = Some("mailto".to_owned());
link.href_origin = None;
}),
("tel: scheme", |link| {
link.href_scheme = Some("tel".to_owned());
link.href_origin = None;
}),
("cross-origin href", |link| {
link.href_origin = Some("https://other.example".to_owned())
}),
("cross-origin via different scheme", |link| {
link.href_scheme = Some("http".to_owned());
link.href_origin = Some("http://example.com".to_owned());
}),
("pure fragment link", |link| link.is_pure_fragment = true),
("missing scheme", |link| {
link.href_scheme = None;
}),
];
for (description, mutate) in cases {
let mut link = navigable_link();
mutate(&mut link);
assert!(
!should_intercept(&link),
"expected opt-out (no interception) for case: {description}",
);
}
}
#[test]
fn target_self_does_not_count_as_foreign() {
let link = navigable_link();
assert!(should_intercept(&link));
}
#[test]
fn rel_without_external_token_is_still_navigable() {
let mut link = navigable_link();
link.rel_tokens = vec!["noopener".to_owned(), "noreferrer".to_owned()];
assert!(should_intercept(&link));
}
#[test]
fn http_same_origin_is_navigable() {
let mut link = navigable_link();
link.href_scheme = Some("http".to_owned());
link.href_origin = Some("http://example.com".to_owned());
link.document_origin = "http://example.com".to_owned();
assert!(should_intercept(&link));
}
}