use crate::config::MarkdownFlavor;
use crate::rule_config_serde::RuleConfig;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
pub(super) const GFM_DISALLOWED_TAGS: &[&str] = &[
"title",
"textarea",
"style",
"xmp",
"iframe",
"noembed",
"noframes",
"script",
"plaintext",
];
pub(super) const SAFE_FIXABLE_TAGS: &[&str] = &[
"em", "i", "strong", "b", "code", "br", "hr", "a", "img", ];
pub(super) const ATTRIBUTE_FIXABLE_TAGS: &[&str] = &["a", "img"];
pub(super) const NO_MARKDOWN_EQUIVALENT: &str = "no-markdown-equivalent";
pub(super) const VOID_ELEMENTS: &[&str] = &[
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr",
];
const MARKDOWN_EQUIVALENT_TAGS: &[&str] = &[
"a", "b", "blockquote", "br", "code", "del", "em", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "li", "ol", "p", "pre", "s", "strike", "strong", "table", "tbody", "td", "th", "thead", "tr", "ul", ];
fn flavor_equivalent_tags(flavor: MarkdownFlavor) -> &'static [&'static str] {
match flavor {
MarkdownFlavor::Pandoc | MarkdownFlavor::Quarto => &["dd", "dl", "dt", "sub", "sup"],
MarkdownFlavor::Obsidian => &["mark"],
_ => &[],
}
}
fn has_markdown_equivalent(tag_name: &str, flavor: MarkdownFlavor, in_table: bool) -> bool {
if in_table && tag_name == "br" {
return false;
}
MARKDOWN_EQUIVALENT_TAGS.binary_search(&tag_name).is_ok() || flavor_equivalent_tags(flavor).contains(&tag_name)
}
pub(super) fn is_permitted_without_markdown_equivalent(tag_name: &str, flavor: MarkdownFlavor, in_table: bool) -> bool {
let lower = tag_name.to_ascii_lowercase();
!GFM_DISALLOWED_TAGS.contains(&lower.as_str()) && !has_markdown_equivalent(&lower, flavor, in_table)
}
fn is_no_markdown_equivalent(value: &str) -> bool {
value.to_lowercase().replace('_', "-") == NO_MARKDOWN_EQUIVALENT
}
pub(super) const SAFE_URL_SCHEMES: &[&str] = &["http://", "https://", "mailto:", "tel:", "ftp://", "ftps://"];
pub(super) const DANGEROUS_URL_SCHEMES: &[&str] = &["javascript:", "vbscript:", "data:", "about:", "blob:", "file:"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BrStyle {
#[default]
TrailingSpaces,
Backslash,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MD033FixMode {
#[default]
Conservative,
Relaxed,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MD033Config {
#[serde(default, rename = "allowed-elements", alias = "allowed_elements", alias = "allowed")]
pub allowed: Vec<String>,
#[serde(default, rename = "allowed-inside", alias = "allowed_inside")]
pub allowed_inside: Vec<String>,
#[serde(
default,
rename = "disallowed-elements",
alias = "disallowed_elements",
alias = "disallowed"
)]
pub disallowed: Vec<String>,
#[serde(default)]
pub fix: bool,
#[serde(default, rename = "fix-mode", alias = "fix_mode")]
pub fix_mode: MD033FixMode,
#[serde(
default = "default_drop_attributes",
rename = "drop-attributes",
alias = "drop_attributes"
)]
pub drop_attributes: Vec<String>,
#[serde(
default = "default_strip_wrapper_elements",
rename = "strip-wrapper-elements",
alias = "strip_wrapper_elements"
)]
pub strip_wrapper_elements: Vec<String>,
#[serde(default, rename = "br-style", alias = "br_style")]
pub br_style: BrStyle,
#[serde(
default,
rename = "table-allowed-elements",
alias = "table_allowed_elements",
alias = "table-allowed",
alias = "table_allowed"
)]
pub table_allowed_elements: Option<Vec<String>>,
}
impl Default for MD033Config {
fn default() -> Self {
Self {
allowed: Vec::new(),
allowed_inside: Vec::new(),
disallowed: Vec::new(),
fix: false,
fix_mode: MD033FixMode::default(),
drop_attributes: default_drop_attributes(),
strip_wrapper_elements: default_strip_wrapper_elements(),
br_style: BrStyle::default(),
table_allowed_elements: None,
}
}
}
fn default_drop_attributes() -> Vec<String> {
vec!["target", "rel", "width", "height", "align", "class", "id", "style"]
.into_iter()
.map(ToString::to_string)
.collect()
}
fn default_strip_wrapper_elements() -> Vec<String> {
vec!["p".to_string()]
}
impl MD033Config {
pub fn allowed_set(&self) -> HashSet<String> {
Self::element_set(&self.allowed)
}
pub fn allowed_inside_set(&self) -> HashSet<String> {
Self::element_set(&self.allowed_inside)
}
pub fn table_allowed_set(&self) -> HashSet<String> {
match &self.table_allowed_elements {
Some(list) => Self::element_set(list),
None => self.allowed_set(),
}
}
pub fn allows_no_markdown_equivalent(&self) -> bool {
self.allowed.iter().any(|value| is_no_markdown_equivalent(value))
}
pub fn table_allows_no_markdown_equivalent(&self) -> bool {
match &self.table_allowed_elements {
Some(list) => list.iter().any(|value| is_no_markdown_equivalent(value)),
None => self.allows_no_markdown_equivalent(),
}
}
fn element_set(values: &[String]) -> HashSet<String> {
values
.iter()
.filter(|value| !is_no_markdown_equivalent(value))
.map(|value| value.to_lowercase())
.collect()
}
pub fn disallowed_set(&self) -> HashSet<String> {
let mut set = HashSet::new();
for tag in &self.disallowed {
let lower = tag.to_lowercase();
if lower == "gfm" {
for gfm_tag in GFM_DISALLOWED_TAGS {
set.insert((*gfm_tag).to_string());
}
} else {
set.insert(lower);
}
}
set
}
pub fn is_disallowed_mode(&self) -> bool {
!self.disallowed.is_empty()
}
pub fn is_safe_fixable_tag(tag_name: &str) -> bool {
SAFE_FIXABLE_TAGS.contains(&tag_name.to_ascii_lowercase().as_str())
}
pub fn requires_attribute_extraction(tag_name: &str) -> bool {
ATTRIBUTE_FIXABLE_TAGS.contains(&tag_name.to_ascii_lowercase().as_str())
}
pub fn drop_attributes_set(&self) -> HashSet<String> {
self.drop_attributes.iter().map(|s| s.to_lowercase()).collect()
}
pub fn strip_wrapper_elements_set(&self) -> HashSet<String> {
self.strip_wrapper_elements.iter().map(|s| s.to_lowercase()).collect()
}
fn decode_percent_encoding(url: &str) -> String {
let mut result = String::with_capacity(url.len());
let mut chars = url.chars().peekable();
while let Some(c) = chars.next() {
if c == '%' {
let hex: String = chars.by_ref().take(2).collect();
if hex.len() == 2
&& let Ok(byte) = u8::from_str_radix(&hex, 16)
{
result.push(byte as char);
continue;
}
result.push('%');
result.push_str(&hex);
} else {
result.push(c);
}
}
result
}
fn decode_html_entities(url: &str) -> String {
url.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace(":", ":")
.replace(":", ":")
.replace(":", ":")
.replace("/", "/")
.replace("/", "/")
.replace("/", "/")
}
pub fn is_safe_url(url: &str) -> bool {
let decoded = Self::decode_percent_encoding(url);
let decoded = Self::decode_html_entities(&decoded);
let url_lower = decoded.to_ascii_lowercase();
let trimmed = url_lower.trim();
if trimmed.is_empty() {
return true;
}
for scheme in DANGEROUS_URL_SCHEMES {
if trimmed.starts_with(scheme) {
return false;
}
}
let dangerous_prefixes: &[&str] = &["javascript", "vbscript", "data", "about", "blob", "file"];
for prefix in dangerous_prefixes {
if let Some(rest) = trimmed.strip_prefix(prefix) {
if rest.starts_with(':') || rest.starts_with("%3a") || rest.starts_with("&#") {
return false;
}
}
}
if trimmed.starts_with('/') || trimmed.starts_with('.') || trimmed.starts_with('#') || trimmed.starts_with('?')
{
return true;
}
for scheme in SAFE_URL_SCHEMES {
if trimmed.starts_with(scheme) {
return true;
}
}
if trimmed.starts_with("//") {
return true;
}
if let Some(colon_pos) = trimmed.find(':') {
if let Some(slash_pos) = trimmed.find('/') {
if colon_pos > slash_pos {
return true;
}
}
false
} else {
true
}
}
}
impl RuleConfig for MD033Config {
const RULE_NAME: &'static str = "MD033";
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tag_lists_searched_by_binary_search_are_sorted() {
assert!(MARKDOWN_EQUIVALENT_TAGS.is_sorted(), "{MARKDOWN_EQUIVALENT_TAGS:?}");
assert!(VOID_ELEMENTS.is_sorted(), "{VOID_ELEMENTS:?}");
}
#[test]
fn no_markdown_equivalent_accepts_the_spellings_a_user_reaches_for() {
for spelling in [
"no-markdown-equivalent",
"no_markdown_equivalent",
"No-Markdown-Equivalent",
] {
assert!(is_no_markdown_equivalent(spelling), "{spelling}");
}
assert!(!is_no_markdown_equivalent("nomarkdownequivalent"));
assert!(!is_no_markdown_equivalent("kbd"));
}
#[test]
fn the_sentinel_names_no_element_of_its_own() {
let config = MD033Config {
allowed: vec!["no-markdown-equivalent".to_string(), "kbd".to_string()],
..MD033Config::default()
};
assert!(config.allows_no_markdown_equivalent());
assert_eq!(config.allowed_set(), HashSet::from(["kbd".to_string()]));
}
#[test]
fn a_table_allowlist_decides_the_sentinel_for_table_cells() {
let sentinel_everywhere = MD033Config {
allowed: vec!["no-markdown-equivalent".to_string()],
..MD033Config::default()
};
assert!(
sentinel_everywhere.table_allows_no_markdown_equivalent(),
"an unset table allowlist falls back to allowed-elements"
);
let table_overrides = MD033Config {
allowed: vec!["no-markdown-equivalent".to_string()],
table_allowed_elements: Some(vec!["br".to_string()]),
..MD033Config::default()
};
assert!(
!table_overrides.table_allows_no_markdown_equivalent(),
"an explicit table allowlist is the whole answer for a table cell"
);
}
#[test]
fn a_hard_break_has_no_equivalent_inside_a_table_cell() {
let flavor = MarkdownFlavor::Standard;
assert!(!is_permitted_without_markdown_equivalent("br", flavor, false));
assert!(is_permitted_without_markdown_equivalent("br", flavor, true));
for tag in ["b", "em", "table", "kbd", "details"] {
assert_eq!(
is_permitted_without_markdown_equivalent(tag, flavor, false),
is_permitted_without_markdown_equivalent(tag, flavor, true),
"{tag}"
);
}
}
#[test]
fn tags_no_reader_renders_are_never_permitted() {
for flavor in [
MarkdownFlavor::Standard,
MarkdownFlavor::Pandoc,
MarkdownFlavor::Obsidian,
] {
for tag in GFM_DISALLOWED_TAGS {
assert!(
!is_permitted_without_markdown_equivalent(tag, flavor, false),
"{tag} under {flavor:?}"
);
assert!(
!is_permitted_without_markdown_equivalent(tag, flavor, true),
"{tag} under {flavor:?} in a table"
);
}
}
}
}