use alloc::borrow::Cow;
use alloc::string::String;
pub fn escape_html(value: &str) -> String {
let mut out = String::with_capacity(value.len() + value.len() / 8);
for ch in value.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
pub fn escape_html_cow(value: &str) -> Cow<'_, str> {
if value
.bytes()
.any(|b| matches!(b, b'&' | b'<' | b'>' | b'"' | b'\''))
{
Cow::Owned(escape_html(value))
} else {
Cow::Borrowed(value)
}
}
pub fn escape_attribute(value: &str) -> String {
let mut out = String::with_capacity(value.len() + value.len() / 8);
for ch in value.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
other => out.push(other),
}
}
out
}
pub fn unescape_html(value: &str) -> String {
const ENTITIES: [(&str, &str); 8] = [
("&", "&"),
("<", "<"),
(">", ">"),
(""", "\""),
("'", "'"),
("'", "'"),
("/", "/"),
("/", "/"),
];
let mut out = String::with_capacity(value.len());
let mut rest = value;
while let Some(amp) = rest.find('&') {
out.push_str(&rest[..amp]);
let tail = &rest[amp..];
match ENTITIES.iter().find(|(name, _)| tail.starts_with(name)) {
Some((name, decoded)) => {
out.push_str(decoded);
rest = &tail[name.len()..];
}
None => {
out.push('&');
rest = &tail[1..];
}
}
}
out.push_str(rest);
out
}
#[cfg(all(feature = "tokio", not(loom)))]
#[cfg_attr(docsrs, doc(cfg(feature = "tokio")))]
pub async fn async_extract_metadata_from_file(
file_path: &str,
) -> Result<
(crate::MetadataMap, crate::Keywords, crate::MetaTagGroups),
crate::error::MetadataError,
> {
crate::tokio::extract_from_file(file_path).await
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(all(feature = "tokio", not(loom)))]
use crate::error::MetadataError;
#[cfg(all(feature = "tokio", not(loom)))]
use tempfile::tempdir;
#[cfg(all(feature = "tokio", not(loom)))]
use tokio::fs::File;
#[cfg(all(feature = "tokio", not(loom)))]
use tokio::io::AsyncWriteExt;
#[test]
fn test_escape_html() {
let input = "Hello, <world> & \"friends\"!";
let expected =
"Hello, <world> & "friends"!";
assert_eq!(escape_html(input), expected);
}
#[test]
fn test_escape_html_special_characters() {
let input = "It's <b>bold</b> & it's <i>italic</i>";
let expected = "It's <b>bold</b> & it's <i>italic</i>";
assert_eq!(escape_html(input), expected);
}
#[test]
fn test_unescape_html() {
let input = "Hello, <world> & "friends"!";
let expected = "Hello, <world> & \"friends\"!";
assert_eq!(unescape_html(input), expected);
}
#[test]
fn test_unescape_html_edge_cases() {
let input = "<&>"''/";
let expected = "<&>\"''/";
assert_eq!(unescape_html(input), expected);
}
#[test]
fn test_escape_unescape_roundtrip() {
let original = "Test <script>alert('XSS');</script> & other \"special\" chars";
let escaped = escape_html(original);
let unescaped = unescape_html(&escaped);
assert_eq!(original, unescaped);
}
#[cfg(all(feature = "tokio", not(loom)))]
#[cfg_attr(
miri,
ignore = "touches the filesystem; Miri isolation forbids it"
)]
#[tokio::test]
async fn test_async_extract_metadata_from_file() {
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("test.md");
let content = r#"---
title: Test Page
description: A test page for metadata extraction
keywords: test, metadata, extraction
---
# Test Content
This is a test file for metadata extraction."#;
let mut file = File::create(&file_path).await.unwrap();
file.write_all(content.as_bytes()).await.unwrap();
file.flush().await.unwrap();
drop(file);
let result = async_extract_metadata_from_file(
file_path.to_str().unwrap(),
)
.await;
assert!(result.is_ok());
let (metadata, keywords, meta_tags) = result.unwrap();
assert_eq!(
metadata.get("title"),
Some(&"Test Page".to_string())
);
assert_eq!(
metadata.get("description"),
Some(&"A test page for metadata extraction".to_string())
);
assert_eq!(keywords, vec!["test", "metadata", "extraction"]);
assert!(!meta_tags.primary.is_empty());
}
#[cfg(all(feature = "tokio", not(loom)))]
#[cfg_attr(
miri,
ignore = "touches the filesystem; Miri isolation forbids it"
)]
#[tokio::test]
async fn test_async_extract_metadata_from_empty_file() {
let temp_dir = tempdir().unwrap();
let file_path = temp_dir.path().join("empty.md");
let mut file = File::create(&file_path).await.unwrap();
file.write_all(b"").await.unwrap();
file.flush().await.unwrap();
drop(file);
let result = async_extract_metadata_from_file(
file_path.to_str().unwrap(),
)
.await;
assert!(result.is_ok());
let (metadata, keywords, meta_tags) = result.unwrap();
assert!(metadata.is_empty());
assert!(keywords.is_empty());
assert!(meta_tags.primary.is_empty());
}
#[cfg(all(feature = "tokio", not(loom)))]
#[cfg_attr(
miri,
ignore = "touches the filesystem; Miri isolation forbids it"
)]
#[tokio::test]
async fn test_async_extract_metadata_from_nonexistent_file() {
let result =
async_extract_metadata_from_file("nonexistent_file.md")
.await;
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
MetadataError::IoError(_)
));
}
}
#[cfg(test)]
mod unescape_single_pass_tests {
use super::*;
#[test]
fn unescape_does_not_rescan_its_own_output() {
assert_eq!(unescape_html("&lt;"), "<");
assert_eq!(unescape_html("&amp;"), "&");
assert_eq!(unescape_html("&#39;"), "'");
}
#[test]
fn escape_then_unescape_is_the_identity() {
for s in [
"&<''",
"a < b && c > \"d\" 'e'",
"&",
"&&",
"plain",
] {
assert_eq!(unescape_html(&escape_html(s)), s, "{s:?}");
}
}
#[test]
fn unknown_and_unterminated_entities_pass_through() {
assert_eq!(unescape_html("&unknown;"), "&unknown;");
assert_eq!(unescape_html("&"), "&");
assert_eq!(unescape_html("& x"), "& x");
assert_eq!(unescape_html("//'"), "//'");
}
}
#[cfg(test)]
mod escape_single_pass_tests {
use super::*;
#[test]
fn escape_html_cow_borrows_clean_input() {
assert!(matches!(
escape_html_cow("plain text"),
Cow::Borrowed(_)
));
for dirty in ["a&b", "a<b", "a>b", "a\"b", "a'b"] {
let out = escape_html_cow(dirty);
assert!(matches!(out, Cow::Owned(_)), "{dirty}");
assert_eq!(out, escape_html(dirty));
}
}
#[test]
fn escape_attribute_keeps_apostrophes() {
assert_eq!(
escape_attribute("<a href=\"x\">it's & that</a>"),
"<a href="x">it's & that</a>"
);
}
#[test]
fn escape_matches_the_replace_chain_it_replaced() {
let reference = |v: &str| {
v.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
};
for s in [
"",
"plain",
"a<b>c&d\"e'f",
"&&&",
"<<>>",
"ünïcödé <tag> & 'q'",
"&",
] {
assert_eq!(escape_html(s), reference(s), "{s:?}");
}
}
}