1use alloc::borrow::Cow;
7use alloc::string::String;
8
9pub fn escape_html(value: &str) -> String {
43 let mut out = String::with_capacity(value.len() + value.len() / 8);
47 for ch in value.chars() {
48 match ch {
49 '&' => out.push_str("&"),
50 '<' => out.push_str("<"),
51 '>' => out.push_str(">"),
52 '"' => out.push_str("""),
53 '\'' => out.push_str("'"),
54 other => out.push(other),
55 }
56 }
57 out
58}
59
60pub fn escape_html_cow(value: &str) -> Cow<'_, str> {
75 if value
76 .bytes()
77 .any(|b| matches!(b, b'&' | b'<' | b'>' | b'"' | b'\''))
78 {
79 Cow::Owned(escape_html(value))
80 } else {
81 Cow::Borrowed(value)
82 }
83}
84
85pub fn escape_attribute(value: &str) -> String {
102 let mut out = String::with_capacity(value.len() + value.len() / 8);
103 for ch in value.chars() {
104 match ch {
105 '&' => out.push_str("&"),
106 '<' => out.push_str("<"),
107 '>' => out.push_str(">"),
108 '"' => out.push_str("""),
109 other => out.push(other),
110 }
111 }
112 out
113}
114
115pub fn unescape_html(value: &str) -> String {
150 const ENTITIES: [(&str, &str); 8] = [
155 ("&", "&"),
156 ("<", "<"),
157 (">", ">"),
158 (""", "\""),
159 ("'", "'"),
160 ("'", "'"),
161 ("/", "/"),
162 ("/", "/"),
163 ];
164 let mut out = String::with_capacity(value.len());
165 let mut rest = value;
166 while let Some(amp) = rest.find('&') {
167 out.push_str(&rest[..amp]);
168 let tail = &rest[amp..];
169 match ENTITIES.iter().find(|(name, _)| tail.starts_with(name)) {
170 Some((name, decoded)) => {
171 out.push_str(decoded);
172 rest = &tail[name.len()..];
173 }
174 None => {
175 out.push('&');
176 rest = &tail[1..];
177 }
178 }
179 }
180 out.push_str(rest);
181 out
182}
183
184#[cfg(all(feature = "tokio", not(loom)))]
210#[cfg_attr(docsrs, doc(cfg(feature = "tokio")))]
211pub async fn async_extract_metadata_from_file(
212 file_path: &str,
213) -> Result<
214 (crate::MetadataMap, crate::Keywords, crate::MetaTagGroups),
215 crate::error::MetadataError,
216> {
217 crate::tokio::extract_from_file(file_path).await
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 #[cfg(all(feature = "tokio", not(loom)))]
224 use crate::error::MetadataError;
225 #[cfg(all(feature = "tokio", not(loom)))]
226 use tempfile::tempdir;
227 #[cfg(all(feature = "tokio", not(loom)))]
228 use tokio::fs::File;
229 #[cfg(all(feature = "tokio", not(loom)))]
230 use tokio::io::AsyncWriteExt;
231
232 #[test]
233 fn test_escape_html() {
234 let input = "Hello, <world> & \"friends\"!";
235 let expected =
236 "Hello, <world> & "friends"!";
237 assert_eq!(escape_html(input), expected);
238 }
239
240 #[test]
241 fn test_escape_html_special_characters() {
242 let input = "It's <b>bold</b> & it's <i>italic</i>";
243 let expected = "It's <b>bold</b> & it's <i>italic</i>";
244 assert_eq!(escape_html(input), expected);
245 }
246
247 #[test]
248 fn test_unescape_html() {
249 let input = "Hello, <world> & "friends"!";
250 let expected = "Hello, <world> & \"friends\"!";
251 assert_eq!(unescape_html(input), expected);
252 }
253
254 #[test]
255 fn test_unescape_html_edge_cases() {
256 let input = "<&>"''/";
257 let expected = "<&>\"''/";
258 assert_eq!(unescape_html(input), expected);
259 }
260
261 #[test]
262 fn test_escape_unescape_roundtrip() {
263 let original = "Test <script>alert('XSS');</script> & other \"special\" chars";
264 let escaped = escape_html(original);
265 let unescaped = unescape_html(&escaped);
266 assert_eq!(original, unescaped);
267 }
268
269 #[cfg(all(feature = "tokio", not(loom)))]
270 #[cfg_attr(
271 miri,
272 ignore = "touches the filesystem; Miri isolation forbids it"
273 )]
274 #[tokio::test]
275 async fn test_async_extract_metadata_from_file() {
276 let temp_dir = tempdir().unwrap();
278 let file_path = temp_dir.path().join("test.md");
279
280 let content = r#"---
282title: Test Page
283description: A test page for metadata extraction
284keywords: test, metadata, extraction
285---
286# Test Content
287This is a test file for metadata extraction."#;
288
289 let mut file = File::create(&file_path).await.unwrap();
290 file.write_all(content.as_bytes()).await.unwrap();
291 file.flush().await.unwrap();
292 drop(file);
293
294 let result = async_extract_metadata_from_file(
296 file_path.to_str().unwrap(),
297 )
298 .await;
299 assert!(result.is_ok());
300
301 let (metadata, keywords, meta_tags) = result.unwrap();
302 assert_eq!(
303 metadata.get("title"),
304 Some(&"Test Page".to_string())
305 );
306 assert_eq!(
307 metadata.get("description"),
308 Some(&"A test page for metadata extraction".to_string())
309 );
310 assert_eq!(keywords, vec!["test", "metadata", "extraction"]);
311 assert!(!meta_tags.primary.is_empty());
312 }
313
314 #[cfg(all(feature = "tokio", not(loom)))]
315 #[cfg_attr(
316 miri,
317 ignore = "touches the filesystem; Miri isolation forbids it"
318 )]
319 #[tokio::test]
320 async fn test_async_extract_metadata_from_empty_file() {
321 let temp_dir = tempdir().unwrap();
322 let file_path = temp_dir.path().join("empty.md");
323
324 let mut file = File::create(&file_path).await.unwrap();
326 file.write_all(b"").await.unwrap();
327 file.flush().await.unwrap();
328 drop(file);
329
330 let result = async_extract_metadata_from_file(
331 file_path.to_str().unwrap(),
332 )
333 .await;
334
335 assert!(result.is_ok());
337 let (metadata, keywords, meta_tags) = result.unwrap();
338 assert!(metadata.is_empty());
339 assert!(keywords.is_empty());
340 assert!(meta_tags.primary.is_empty());
341 }
342
343 #[cfg(all(feature = "tokio", not(loom)))]
344 #[cfg_attr(
345 miri,
346 ignore = "touches the filesystem; Miri isolation forbids it"
347 )]
348 #[tokio::test]
349 async fn test_async_extract_metadata_from_nonexistent_file() {
350 let result =
351 async_extract_metadata_from_file("nonexistent_file.md")
352 .await;
353 assert!(result.is_err());
354 assert!(matches!(
355 result.unwrap_err(),
356 MetadataError::IoError(_)
357 ));
358 }
359}
360
361#[cfg(test)]
362mod unescape_single_pass_tests {
363 use super::*;
370
371 #[test]
372 fn unescape_does_not_rescan_its_own_output() {
373 assert_eq!(unescape_html("&lt;"), "<");
374 assert_eq!(unescape_html("&amp;"), "&");
375 assert_eq!(unescape_html("&#39;"), "'");
376 }
377
378 #[test]
379 fn escape_then_unescape_is_the_identity() {
380 for s in [
381 "&<''",
382 "a < b && c > \"d\" 'e'",
383 "&",
384 "&&",
385 "plain",
386 ] {
387 assert_eq!(unescape_html(&escape_html(s)), s, "{s:?}");
388 }
389 }
390
391 #[test]
392 fn unknown_and_unterminated_entities_pass_through() {
393 assert_eq!(unescape_html("&unknown;"), "&unknown;");
394 assert_eq!(unescape_html("&"), "&");
395 assert_eq!(unescape_html("& x"), "& x");
396 assert_eq!(unescape_html("//'"), "//'");
397 }
398}
399
400#[cfg(test)]
401mod escape_single_pass_tests {
402 use super::*;
403
404 #[test]
405 fn escape_html_cow_borrows_clean_input() {
406 assert!(matches!(
407 escape_html_cow("plain text"),
408 Cow::Borrowed(_)
409 ));
410 for dirty in ["a&b", "a<b", "a>b", "a\"b", "a'b"] {
411 let out = escape_html_cow(dirty);
412 assert!(matches!(out, Cow::Owned(_)), "{dirty}");
413 assert_eq!(out, escape_html(dirty));
414 }
415 }
416
417 #[test]
418 fn escape_attribute_keeps_apostrophes() {
419 assert_eq!(
420 escape_attribute("<a href=\"x\">it's & that</a>"),
421 "<a href="x">it's & that</a>"
422 );
423 }
424
425 #[test]
426 fn escape_matches_the_replace_chain_it_replaced() {
427 let reference = |v: &str| {
428 v.replace('&', "&")
429 .replace('<', "<")
430 .replace('>', ">")
431 .replace('"', """)
432 .replace('\'', "'")
433 };
434 for s in [
435 "",
436 "plain",
437 "a<b>c&d\"e'f",
438 "&&&",
439 "<<>>",
440 "ünïcödé <tag> & 'q'",
441 "&",
442 ] {
443 assert_eq!(escape_html(s), reference(s), "{s:?}");
444 }
445 }
446}