Skip to main content

metadata_gen/
utils.rs

1//! Utility functions for metadata processing and HTML manipulation.
2//!
3//! This module provides various utility functions for tasks such as HTML escaping,
4//! asynchronous file reading, and metadata extraction from files.
5
6use alloc::borrow::Cow;
7use alloc::string::String;
8
9/// Escapes special HTML characters in a string.
10///
11/// This function replaces the following characters with their HTML entity equivalents:
12/// - `&` becomes `&`
13/// - `<` becomes `&lt;`
14/// - `>` becomes `&gt;`
15/// - `"` becomes `&quot;`
16/// - `'` becomes `&#x27;`
17///
18/// # Arguments
19///
20/// * `value` - The string to escape.
21///
22/// # Returns
23///
24/// A new string with special HTML characters escaped.
25///
26/// # Examples
27///
28/// ```
29/// use metadata_gen::utils::escape_html;
30///
31/// let input = "Hello, <world>!";
32/// let expected = "Hello, &lt;world&gt;!";
33///
34/// assert_eq!(escape_html(input), expected);
35/// ```
36///
37/// # Security
38///
39/// This function is designed to prevent XSS (Cross-Site Scripting) attacks by escaping
40/// potentially dangerous characters. However, it should not be relied upon as the sole
41/// method of sanitizing user input for use in HTML contexts.
42pub fn escape_html(value: &str) -> String {
43    // One pass, one allocation. The five-`replace` chain this replaces
44    // walked the string five times and allocated up to five
45    // intermediates; the output is byte-identical.
46    let mut out = String::with_capacity(value.len() + value.len() / 8);
47    for ch in value.chars() {
48        match ch {
49            '&' => out.push_str("&amp;"),
50            '<' => out.push_str("&lt;"),
51            '>' => out.push_str("&gt;"),
52            '"' => out.push_str("&quot;"),
53            '\'' => out.push_str("&#x27;"),
54            other => out.push(other),
55        }
56    }
57    out
58}
59
60/// Escapes HTML without allocating when nothing needs escaping.
61///
62/// Returns `Cow::Borrowed(value)` when `value` contains none of `&`, `<`,
63/// `>`, `"` or `'`, and otherwise the same string [`escape_html`] produces.
64///
65/// # Example
66///
67/// ```
68/// use metadata_gen::utils::escape_html_cow;
69/// use std::borrow::Cow;
70///
71/// assert!(matches!(escape_html_cow("plain"), Cow::Borrowed("plain")));
72/// assert_eq!(escape_html_cow("a < b"), "a &lt; b");
73/// ```
74pub 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
85/// Escapes a value for use inside a double-quoted HTML attribute.
86///
87/// `&`, `<`, `>` and `"` are replaced; an apostrophe is left alone because
88/// it cannot terminate a double-quoted attribute, which keeps rendered
89/// meta tags readable.
90///
91/// # Example
92///
93/// ```
94/// use metadata_gen::escape_attribute;
95///
96/// assert_eq!(
97///     escape_attribute(r#"Fish & "Chips" <it's>"#),
98///     "Fish &amp; &quot;Chips&quot; &lt;it's&gt;"
99/// );
100/// ```
101pub 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("&amp;"),
106            '<' => out.push_str("&lt;"),
107            '>' => out.push_str("&gt;"),
108            '"' => out.push_str("&quot;"),
109            other => out.push(other),
110        }
111    }
112    out
113}
114
115/// Unescapes HTML entities in a string.
116///
117/// This function replaces HTML entities with their corresponding characters:
118/// - `&amp;` becomes `&`
119/// - `&lt;` becomes `<`
120/// - `&gt;` becomes `>`
121/// - `&quot;` becomes `"`
122/// - `&#x27;` and `&#39;` become `'`
123/// - `&#x2F;` and `&#x2f;` become `/`
124///
125/// # Arguments
126///
127/// * `value` - The string to unescape.
128///
129/// # Returns
130///
131/// A new string with HTML entities unescaped.
132///
133/// # Examples
134///
135/// ```
136/// use metadata_gen::utils::unescape_html;
137///
138/// let input = "Hello, &lt;world&gt;!";
139/// let expected = "Hello, <world>!";
140///
141/// assert_eq!(unescape_html(input), expected);
142/// ```
143///
144/// # Security
145///
146/// This function should be used with caution, especially on user-supplied input,
147/// as it can potentially introduce security vulnerabilities if the unescaped content
148/// is then rendered as HTML.
149pub fn unescape_html(value: &str) -> String {
150    // One left-to-right pass. Each entity is decoded exactly once and the
151    // decoded text is never rescanned, so `&amp;lt;` yields `&lt;`, not
152    // `<`. A chain of `replace` calls did rescan, and the fuzz target
153    // caught it on its seed corpus: escape/unescape was not an identity.
154    const ENTITIES: [(&str, &str); 8] = [
155        ("&amp;", "&"),
156        ("&lt;", "<"),
157        ("&gt;", ">"),
158        ("&quot;", "\""),
159        ("&#x27;", "'"),
160        ("&#39;", "'"),
161        ("&#x2F;", "/"),
162        ("&#x2f;", "/"),
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/// Asynchronously extracts metadata from a file (feature `tokio`).
185///
186/// Kept for callers of earlier releases; new code should use
187/// [`crate::tokio::extract_from_file`], which accepts any path type.
188///
189/// # Errors
190///
191/// As [`crate::tokio::extract_from_file`].
192///
193/// # Security
194///
195/// This function reads files from the file system. Ensure that the `file_path`
196/// is properly sanitized and validated to prevent potential security issues like
197/// path traversal attacks.
198///
199/// # Example
200///
201/// ```no_run
202/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
203/// use metadata_gen::utils::async_extract_metadata_from_file;
204///
205/// let result = async_extract_metadata_from_file("content/post.md").await;
206/// # let _ = result;
207/// # });
208/// ```
209#[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, &lt;world&gt; &amp; &quot;friends&quot;!";
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&#x27;s &lt;b&gt;bold&lt;/b&gt; &amp; it&#x27;s &lt;i&gt;italic&lt;/i&gt;";
244        assert_eq!(escape_html(input), expected);
245    }
246
247    #[test]
248    fn test_unescape_html() {
249        let input = "Hello, &lt;world&gt; &amp; &quot;friends&quot;!";
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 = "&lt;&amp;&gt;&quot;&#x27;&#39;&#x2F;";
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        // Create a temporary directory and file
277        let temp_dir = tempdir().unwrap();
278        let file_path = temp_dir.path().join("test.md");
279
280        // Write test content to the file
281        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        // Test the async_extract_metadata_from_file function
295        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        // Create an empty file
325        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        // Ensure the result is empty metadata, keywords, and meta tags
336        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    //! Found by `fuzz_html_escape` on its seed corpus: unescaping was a
364    //! chain of `replace` calls, so `&amp;lt;` decoded to `<` — the
365    //! output of one replacement was re-read by the next. A decoder
366    //! that can turn an escaped `&lt;` back into a raw `<` undoes the
367    //! escaping that `escape_html` exists to provide.
368
369    use super::*;
370
371    #[test]
372    fn unescape_does_not_rescan_its_own_output() {
373        assert_eq!(unescape_html("&amp;lt;"), "&lt;");
374        assert_eq!(unescape_html("&amp;amp;"), "&amp;");
375        assert_eq!(unescape_html("&amp;#39;"), "&#39;");
376    }
377
378    #[test]
379    fn escape_then_unescape_is_the_identity() {
380        for s in [
381            "&amp;&lt;&#39;&#x27;",
382            "a < b && c > \"d\" 'e'",
383            "&",
384            "&&amp;",
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("&amp"), "&amp");
395        assert_eq!(unescape_html("& x"), "& x");
396        assert_eq!(unescape_html("&#x2F;&#x2f;&#39;"), "//'");
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            "&lt;a href=&quot;x&quot;&gt;it's &amp; that&lt;/a&gt;"
422        );
423    }
424
425    #[test]
426    fn escape_matches_the_replace_chain_it_replaced() {
427        let reference = |v: &str| {
428            v.replace('&', "&amp;")
429                .replace('<', "&lt;")
430                .replace('>', "&gt;")
431                .replace('"', "&quot;")
432                .replace('\'', "&#x27;")
433        };
434        for s in [
435            "",
436            "plain",
437            "a<b>c&d\"e'f",
438            "&&&",
439            "<<>>",
440            "ünïcödé <tag> & 'q'",
441            "&amp;",
442        ] {
443            assert_eq!(escape_html(s), reference(s), "{s:?}");
444        }
445    }
446}