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("&"),
25 '<' => out.push_str("<"),
26 '>' => out.push_str(">"),
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("<"),
52 '>' => out.push_str(">"),
53 '&' => out.push_str("&"),
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("<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}