Skip to main content

rustinel_core/
markdown.rs

1//! Markdown output helpers.
2//!
3//! Untrusted content (crate names, advisory titles, license strings) flows into
4//! PR comments. To prevent Markdown/HTML injection we escape such content before
5//! interpolation. This is a defensive output-encoding concern, not cosmetics.
6
7/// Escape a string for safe inline rendering inside Markdown.
8///
9/// The goal is injection safety, not escaping every punctuation mark. We:
10/// - entity-encode HTML specials (`<`, `>`, `&`) so raw HTML can never be
11///   injected into a rendered comment;
12/// - backslash-escape the Markdown characters that let attacker text *break out*
13///   of inline context — code spans (`` ` ``), emphasis (`*`, `_`), links/images
14///   (`[`, `]`), tables (`|`), and a literal backslash;
15/// - flatten control characters (including CR/LF) that could forge new lines or
16///   block-level constructs.
17///
18/// Cosmetic-only characters (`. - ! + # ( ) { }`) are left as-is: with newlines
19/// stripped they cannot start a block, so escaping them only produces noise.
20pub fn escape(input: &str) -> String {
21    let mut out = String::with_capacity(input.len() + 8);
22    for ch in input.chars() {
23        match ch {
24            '&' => out.push_str("&amp;"),
25            '<' => out.push_str("&lt;"),
26            '>' => out.push_str("&gt;"),
27            '\\' | '`' | '*' | '_' | '[' | ']' | '|' => {
28                out.push('\\');
29                out.push(ch);
30            }
31            // Strip control characters (including CR/LF) that could be used to
32            // forge new Markdown lines/sections.
33            c if c.is_control() => out.push(' '),
34            c => out.push(c),
35        }
36    }
37    out
38}
39
40/// Escape for use inside an inline code span (backticks).
41///
42/// Backticks terminate the span, so they are replaced. Angle brackets and
43/// ampersands are entity-encoded as defense-in-depth: a Markdown code span
44/// renders its content literally, but we never want raw `<script>`-style text to
45/// reach a consumer that might mis-handle it. Newlines are flattened.
46pub fn escape_code(input: &str) -> String {
47    let mut out = String::with_capacity(input.len());
48    for c in input.chars() {
49        match c {
50            '`' => out.push('\''),
51            '<' => out.push_str("&lt;"),
52            '>' => out.push_str("&gt;"),
53            '&' => out.push_str("&amp;"),
54            c if c.is_control() => out.push(' '),
55            c => out.push(c),
56        }
57    }
58    out
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    #[test]
66    fn neutralizes_html() {
67        let out = escape("<img src=x onerror=alert(1)>");
68        assert!(!out.contains('<'));
69        assert!(!out.contains('>'));
70        assert!(out.contains("&lt;img"));
71    }
72
73    #[test]
74    fn strips_newlines() {
75        let out = escape("line1\n## injected heading");
76        assert!(!out.contains('\n'));
77    }
78
79    #[test]
80    fn code_span_safe() {
81        let out = escape_code("evil`code`span");
82        assert!(!out.contains('`'));
83    }
84}