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 crate::error::MetadataError;
7use crate::extract_and_prepare_metadata;
8use crate::metatags::MetaTagGroups;
9use std::collections::HashMap;
10use tokio::fs::File;
11use tokio::io::AsyncReadExt;
12
13/// Escapes special HTML characters in a string.
14///
15/// This function replaces the following characters with their HTML entity equivalents:
16/// - `&` becomes `&`
17/// - `<` becomes `&lt;`
18/// - `>` becomes `&gt;`
19/// - `"` becomes `&quot;`
20/// - `'` becomes `&#x27;`
21///
22/// # Arguments
23///
24/// * `value` - The string to escape.
25///
26/// # Returns
27///
28/// A new string with special HTML characters escaped.
29///
30/// # Examples
31///
32/// ```
33/// use metadata_gen::utils::escape_html;
34///
35/// let input = "Hello, <world>!";
36/// let expected = "Hello, &lt;world&gt;!";
37///
38/// assert_eq!(escape_html(input), expected);
39/// ```
40///
41/// # Security
42///
43/// This function is designed to prevent XSS (Cross-Site Scripting) attacks by escaping
44/// potentially dangerous characters. However, it should not be relied upon as the sole
45/// method of sanitizing user input for use in HTML contexts.
46pub fn escape_html(value: &str) -> String {
47    // One pass, one allocation. The five-`replace` chain this replaces
48    // walked the string five times and allocated up to five
49    // intermediates; the output is byte-identical.
50    let mut out = String::with_capacity(value.len() + value.len() / 8);
51    for ch in value.chars() {
52        match ch {
53            '&' => out.push_str("&amp;"),
54            '<' => out.push_str("&lt;"),
55            '>' => out.push_str("&gt;"),
56            '"' => out.push_str("&quot;"),
57            '\'' => out.push_str("&#x27;"),
58            other => out.push(other),
59        }
60    }
61    out
62}
63
64/// Unescapes HTML entities in a string.
65///
66/// This function replaces HTML entities with their corresponding characters:
67/// - `&amp;` becomes `&`
68/// - `&lt;` becomes `<`
69/// - `&gt;` becomes `>`
70/// - `&quot;` becomes `"`
71/// - `&#x27;` and `&#39;` become `'`
72/// - `&#x2F;` and `&#x2f;` become `/`
73///
74/// # Arguments
75///
76/// * `value` - The string to unescape.
77///
78/// # Returns
79///
80/// A new string with HTML entities unescaped.
81///
82/// # Examples
83///
84/// ```
85/// use metadata_gen::utils::unescape_html;
86///
87/// let input = "Hello, &lt;world&gt;!";
88/// let expected = "Hello, <world>!";
89///
90/// assert_eq!(unescape_html(input), expected);
91/// ```
92///
93/// # Security
94///
95/// This function should be used with caution, especially on user-supplied input,
96/// as it can potentially introduce security vulnerabilities if the unescaped content
97/// is then rendered as HTML.
98pub fn unescape_html(value: &str) -> String {
99    // One left-to-right pass. Each entity is decoded exactly once and the
100    // decoded text is never rescanned, so `&amp;lt;` yields `&lt;`, not
101    // `<`. A chain of `replace` calls did rescan, and the fuzz target
102    // caught it on its seed corpus: escape/unescape was not an identity.
103    const ENTITIES: [(&str, &str); 8] = [
104        ("&amp;", "&"),
105        ("&lt;", "<"),
106        ("&gt;", ">"),
107        ("&quot;", "\""),
108        ("&#x27;", "'"),
109        ("&#39;", "'"),
110        ("&#x2F;", "/"),
111        ("&#x2f;", "/"),
112    ];
113    let mut out = String::with_capacity(value.len());
114    let mut rest = value;
115    while let Some(amp) = rest.find('&') {
116        out.push_str(&rest[..amp]);
117        let tail = &rest[amp..];
118        match ENTITIES.iter().find(|(name, _)| tail.starts_with(name)) {
119            Some((name, decoded)) => {
120                out.push_str(decoded);
121                rest = &tail[name.len()..];
122            }
123            None => {
124                out.push('&');
125                rest = &tail[1..];
126            }
127        }
128    }
129    out.push_str(rest);
130    out
131}
132
133/// Asynchronously reads a file and extracts metadata from its content.
134///
135/// This function reads the content of a file asynchronously and then extracts
136/// metadata, generates keywords, and prepares meta tag groups.
137///
138/// # Arguments
139///
140/// * `file_path` - A string slice representing the path to the file.
141///
142/// # Returns
143///
144/// Returns a Result containing a tuple with:
145/// * `HashMap<String, String>`: Extracted metadata
146/// * `Vec<String>`: A list of keywords
147/// * `MetaTagGroups`: A structure containing various meta tags
148///
149/// # Errors
150///
151/// This function will return a `MetadataError` if:
152/// - File reading fails (e.g., file not found, permission denied)
153/// - Metadata extraction or processing fails
154///
155/// # Examples
156///
157/// ```no_run
158/// use metadata_gen::utils::async_extract_metadata_from_file;
159///
160/// #[tokio::main]
161/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
162///     let (metadata, keywords, meta_tags) = async_extract_metadata_from_file("path/to/file.md").await?;
163///     println!("Metadata: {:?}", metadata);
164///     println!("Keywords: {:?}", keywords);
165///     println!("Meta tags: {}", meta_tags);
166///     Ok(())
167/// }
168/// ```
169///
170/// # Security
171///
172/// This function reads files from the file system. Ensure that the `file_path`
173/// is properly sanitized and validated to prevent potential security issues like
174/// path traversal attacks.
175pub async fn async_extract_metadata_from_file(
176    file_path: &str,
177) -> Result<
178    (HashMap<String, String>, Vec<String>, MetaTagGroups),
179    MetadataError,
180> {
181    let mut file = File::open(file_path)
182        .await
183        .map_err(MetadataError::IoError)?;
184
185    let mut content = String::new();
186    file.read_to_string(&mut content)
187        .await
188        .map_err(MetadataError::IoError)?;
189
190    if content.trim().is_empty() {
191        // If file is empty, return empty structures
192        return Ok((
193            HashMap::new(),
194            Vec::new(),
195            MetaTagGroups {
196                primary: String::new(),
197                apple: String::new(),
198                ms: String::new(),
199                og: String::new(),
200                twitter: String::new(),
201            },
202        ));
203    }
204
205    extract_and_prepare_metadata(&content)
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use tempfile::tempdir;
212    use tokio::fs::File;
213    use tokio::io::AsyncWriteExt;
214
215    #[cfg_attr(
216        miri,
217        ignore = "touches the filesystem; Miri isolation forbids it"
218    )]
219    #[test]
220    fn test_escape_html() {
221        let input = "Hello, <world> & \"friends\"!";
222        let expected =
223            "Hello, &lt;world&gt; &amp; &quot;friends&quot;!";
224        assert_eq!(escape_html(input), expected);
225    }
226
227    #[cfg_attr(
228        miri,
229        ignore = "touches the filesystem; Miri isolation forbids it"
230    )]
231    #[test]
232    fn test_escape_html_special_characters() {
233        let input = "It's <b>bold</b> & it's <i>italic</i>";
234        let expected = "It&#x27;s &lt;b&gt;bold&lt;/b&gt; &amp; it&#x27;s &lt;i&gt;italic&lt;/i&gt;";
235        assert_eq!(escape_html(input), expected);
236    }
237
238    #[cfg_attr(
239        miri,
240        ignore = "touches the filesystem; Miri isolation forbids it"
241    )]
242    #[test]
243    fn test_unescape_html() {
244        let input = "Hello, &lt;world&gt; &amp; &quot;friends&quot;!";
245        let expected = "Hello, <world> & \"friends\"!";
246        assert_eq!(unescape_html(input), expected);
247    }
248
249    #[cfg_attr(
250        miri,
251        ignore = "touches the filesystem; Miri isolation forbids it"
252    )]
253    #[test]
254    fn test_unescape_html_edge_cases() {
255        let input = "&lt;&amp;&gt;&quot;&#x27;&#39;&#x2F;";
256        let expected = "<&>\"''/";
257        assert_eq!(unescape_html(input), expected);
258    }
259
260    #[cfg_attr(
261        miri,
262        ignore = "touches the filesystem; Miri isolation forbids it"
263    )]
264    #[test]
265    fn test_escape_unescape_roundtrip() {
266        let original = "Test <script>alert('XSS');</script> & other \"special\" chars";
267        let escaped = escape_html(original);
268        let unescaped = unescape_html(&escaped);
269        assert_eq!(original, unescaped);
270    }
271
272    #[cfg_attr(
273        miri,
274        ignore = "touches the filesystem; Miri isolation forbids it"
275    )]
276    #[tokio::test]
277    async fn test_async_extract_metadata_from_file() {
278        // Create a temporary directory and file
279        let temp_dir = tempdir().unwrap();
280        let file_path = temp_dir.path().join("test.md");
281
282        // Write test content to the file
283        let content = r#"---
284title: Test Page
285description: A test page for metadata extraction
286keywords: test, metadata, extraction
287---
288# Test Content
289This is a test file for metadata extraction."#;
290
291        let mut file = File::create(&file_path).await.unwrap();
292        file.write_all(content.as_bytes()).await.unwrap();
293        file.flush().await.unwrap();
294        drop(file);
295
296        // Test the async_extract_metadata_from_file function
297        let result = async_extract_metadata_from_file(
298            file_path.to_str().unwrap(),
299        )
300        .await;
301        assert!(result.is_ok());
302
303        let (metadata, keywords, meta_tags) = result.unwrap();
304        assert_eq!(
305            metadata.get("title"),
306            Some(&"Test Page".to_string())
307        );
308        assert_eq!(
309            metadata.get("description"),
310            Some(&"A test page for metadata extraction".to_string())
311        );
312        assert_eq!(keywords, vec!["test", "metadata", "extraction"]);
313        assert!(!meta_tags.primary.is_empty());
314    }
315
316    #[cfg_attr(
317        miri,
318        ignore = "touches the filesystem; Miri isolation forbids it"
319    )]
320    #[tokio::test]
321    async fn test_async_extract_metadata_from_empty_file() {
322        let temp_dir = tempdir().unwrap();
323        let file_path = temp_dir.path().join("empty.md");
324
325        // Create an empty file
326        let mut file = File::create(&file_path).await.unwrap();
327        file.write_all(b"").await.unwrap();
328        file.flush().await.unwrap();
329        drop(file);
330
331        let result = async_extract_metadata_from_file(
332            file_path.to_str().unwrap(),
333        )
334        .await;
335
336        // Ensure the result is empty metadata, keywords, and meta tags
337        assert!(result.is_ok());
338        let (metadata, keywords, meta_tags) = result.unwrap();
339        assert!(metadata.is_empty());
340        assert!(keywords.is_empty());
341        assert!(meta_tags.primary.is_empty());
342    }
343
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_matches_the_replace_chain_it_replaced() {
406        let reference = |v: &str| {
407            v.replace('&', "&amp;")
408                .replace('<', "&lt;")
409                .replace('>', "&gt;")
410                .replace('"', "&quot;")
411                .replace('\'', "&#x27;")
412        };
413        for s in [
414            "",
415            "plain",
416            "a<b>c&d\"e'f",
417            "&&&",
418            "<<>>",
419            "ünïcödé <tag> & 'q'",
420            "&amp;",
421        ] {
422            assert_eq!(escape_html(s), reference(s), "{s:?}");
423        }
424    }
425}