Skip to main content

rusty_figlet/export/
html.rs

1//! HTML5 export backend (E012 US2 — FR-005, FR-014, AD-004, HINT-004).
2//!
3//! ## Safe-to-embed guarantee
4//!
5//! Output produced by [`write_html`] is safe to embed verbatim inside an
6//! HTML5 document. The hand-rolled 4-char escape table (per AD-004)
7//! covers every metacharacter that can break out of either text-content
8//! position or a **double-quoted** attribute position:
9//!
10//! | Byte | Escape    | Position(s) protected             |
11//! |------|-----------|-----------------------------------|
12//! | `<`  | `&lt;`    | text + attribute                  |
13//! | `>`  | `&gt;`    | text + attribute                  |
14//! | `&`  | `&amp;`   | text + attribute                  |
15//! | `"`  | `&quot;`  | attribute (double-quoted) only    |
16//!
17//! ## Double-quoted-attribute constraint (HINT-004)
18//!
19//! Every attribute value emitted by this backend uses `"..."` quoting.
20//! Single quotes (`'`) are NOT escaped because they are not metacharacters
21//! inside `"..."` quoting. The SVG and HTML backends both consume this
22//! same 4-char table — any future backend that emits single-quoted
23//! attributes MUST add `'` to the escape set (per AD-004).
24//!
25//! ## XSS posture
26//!
27//! Library callers can pass arbitrary user-controlled strings into a
28//! [`crate::filter::RenderGrid`] via [`crate::filter::Cell::ch`]; the
29//! escape table protects against:
30//! - `<script>` payload injection (`<` is escaped → `&lt;script&gt;`).
31//! - Attribute-injection (`"` is escaped → `"><img onerror=x"` cannot
32//!   close a surrounding attribute).
33//! - Double-encoding (`&` is escaped → `&amp;` collisions are explicit).
34//!
35//! Fuzz harness `fuzz/fuzz_targets/html_escape.rs` (T050) enforces the
36//! property:
37//!   `output contains no unescaped < > "` AND `len(output) ≤ 6 × len(input)`.
38//!
39//! ## Pre-sized writer (FR-027)
40//!
41//! [`write_html`] allocates `String::with_capacity(w * h * 32)` up front
42//! to amortize realloc cost. The factor 32 covers `<span style="color:#RRGGBB">X</span>`
43//! plus newlines + escape expansion in the typical case.
44
45use super::common::{color_to_hex, escape_into};
46use crate::filter::{Cell, RenderGrid};
47
48/// Encode `grid` as an HTML5 fragment.
49///
50/// Output shape:
51/// ```html
52/// <pre>
53/// <span style="color:#RRGGBB">XX</span><span style="color:#RRGGBB">Y</span>
54/// ...
55/// </pre>
56/// ```
57///
58/// Adjacent cells with identical foreground colors are coalesced into a
59/// single `<span>` to keep the output compact. The writer is pre-sized
60/// per FR-027 (`String::with_capacity(w * h * 32)`).
61#[must_use]
62pub fn write_html(grid: &RenderGrid) -> String {
63    let w = grid.width as usize;
64    let h = grid.height as usize;
65    let capacity = w.saturating_mul(h).saturating_mul(32).saturating_add(64);
66    let mut out = String::with_capacity(capacity);
67
68    out.push_str("<pre>\n");
69    if w == 0 || h == 0 {
70        out.push_str("</pre>\n");
71        return out;
72    }
73
74    for row in &grid.cells {
75        // Coalesce adjacent same-color runs into one <span> per AD-004.
76        let mut i = 0;
77        while i < row.len() {
78            let run_color = row[i].fg;
79            let mut j = i + 1;
80            while j < row.len() && row[j].fg == run_color {
81                j += 1;
82            }
83            // Emit one span for cells[i..j].
84            out.push_str("<span style=\"color:");
85            // color hex is numeric only — no escape needed but we still
86            // emit it into the double-quoted attribute slot per HINT-004.
87            let hex = color_to_hex(run_color);
88            out.push_str(&hex);
89            out.push_str("\">");
90            for cell in &row[i..j] {
91                escape_into(&mut out, &cell_to_string(cell));
92            }
93            out.push_str("</span>");
94            i = j;
95        }
96        out.push('\n');
97    }
98    out.push_str("</pre>\n");
99    out
100}
101
102/// Convert a single [`Cell`] to its single-char string representation.
103fn cell_to_string(cell: &Cell) -> String {
104    cell.ch.to_string()
105}
106
107#[cfg(test)]
108mod tests {
109    use super::super::common::{escape_into, index_to_rgb};
110    use super::*;
111    use crate::filter::{Cell, Color, NamedColor, RenderGrid};
112
113    #[test]
114    fn empty_grid_emits_pre_wrapper() {
115        let grid = RenderGrid::empty();
116        let html = write_html(&grid);
117        assert!(html.starts_with("<pre>"));
118        assert!(html.contains("</pre>"));
119    }
120
121    #[test]
122    fn plain_ascii_pass_through() {
123        let grid = RenderGrid::from_text_rows(&[String::from("Hello")]);
124        let html = write_html(&grid);
125        assert!(html.contains("Hello"));
126        assert!(html.contains("<pre>"));
127    }
128
129    #[test]
130    fn escape_lt_gt_amp_quot() {
131        let mut s = String::new();
132        escape_into(&mut s, "<>&\"");
133        assert_eq!(s, "&lt;&gt;&amp;&quot;");
134    }
135
136    #[test]
137    fn escape_script_tag() {
138        let mut s = String::new();
139        escape_into(&mut s, "<script>");
140        assert_eq!(s, "&lt;script&gt;");
141    }
142
143    #[test]
144    fn escape_attribute_breakout() {
145        // The classic `"><img onerror=...>` payload. After escape, the
146        // double quote and angle brackets are all neutralized.
147        let mut s = String::new();
148        escape_into(&mut s, "\"><img onerror=alert(1)>");
149        assert!(!s.contains('"'));
150        assert!(!s.contains('<'));
151        assert!(!s.contains('>'));
152        assert!(s.contains("&quot;"));
153        assert!(s.contains("&lt;img"));
154    }
155
156    #[test]
157    fn escape_ampersand_double_encoding() {
158        // Probe for & double-encoding behavior. `&amp;` should become
159        // `&amp;amp;` because each & is escaped exactly once.
160        let mut s = String::new();
161        escape_into(&mut s, "&amp;");
162        assert_eq!(s, "&amp;amp;");
163    }
164
165    #[test]
166    fn escape_passes_through_cjk() {
167        let mut s = String::new();
168        escape_into(&mut s, "中文");
169        assert_eq!(s, "中文");
170    }
171
172    #[test]
173    fn escape_passes_through_emoji() {
174        let mut s = String::new();
175        escape_into(&mut s, "🦀");
176        assert_eq!(s, "🦀");
177    }
178
179    #[test]
180    fn escape_single_quote_unchanged() {
181        // Single quote is NOT in the escape set because we only emit
182        // double-quoted attributes (HINT-004).
183        let mut s = String::new();
184        escape_into(&mut s, "it's");
185        assert_eq!(s, "it's");
186    }
187
188    #[test]
189    fn write_html_with_rgb_color() {
190        let cell = Cell {
191            ch: 'X',
192            fg: Color::Rgb(255, 128, 0),
193            bg: None,
194            attrs: 0,
195        };
196        let grid = RenderGrid::from_rows(vec![vec![cell]]);
197        let html = write_html(&grid);
198        assert!(html.contains("#FF8000"));
199        assert!(html.contains(">X</span>"));
200    }
201
202    #[test]
203    fn write_html_with_named_color() {
204        let cell = Cell {
205            ch: 'Y',
206            fg: Color::Named(NamedColor::BrightRed),
207            bg: None,
208            attrs: 0,
209        };
210        let grid = RenderGrid::from_rows(vec![vec![cell]]);
211        let html = write_html(&grid);
212        assert!(html.contains("#FF0000"));
213    }
214
215    #[test]
216    fn write_html_no_unescaped_metacharacters_in_output_for_xss_input() {
217        let cell = Cell {
218            ch: '<',
219            fg: Color::default(),
220            bg: None,
221            attrs: 0,
222        };
223        let grid = RenderGrid::from_rows(vec![vec![cell]]);
224        let html = write_html(&grid);
225        // The structural `<` characters come from `<pre>` and `<span>`,
226        // but the cell's `<` must NOT appear unescaped. Verify by
227        // checking that we see `&lt;` exactly where the cell content
228        // sits — between `>` and `<` of the span tags.
229        assert!(html.contains(">&lt;</span>"));
230    }
231
232    #[test]
233    fn write_html_coalesces_same_color_run() {
234        let g = RenderGrid::from_text_rows(&[String::from("ABC")]);
235        let html = write_html(&g);
236        // All three cells share default color → one <span> per row,
237        // not three.
238        let span_count = html.matches("<span").count();
239        assert_eq!(span_count, 1);
240    }
241
242    #[test]
243    fn index_to_rgb_grayscale_ramp() {
244        // 232 = darkest gray (8,8,8); 255 = lightest (238,238,238).
245        assert_eq!(index_to_rgb(232), 0x080808);
246        assert_eq!(index_to_rgb(255), 0xEEEEEE);
247    }
248
249    #[test]
250    fn index_to_rgb_cube_corner() {
251        // Index 16 = (0,0,0); 231 = (255,255,255).
252        assert_eq!(index_to_rgb(16), 0x000000);
253        assert_eq!(index_to_rgb(231), 0xFFFFFF);
254    }
255
256    #[test]
257    fn write_html_has_pre_wrapper_around_all_content() {
258        let grid = RenderGrid::from_text_rows(&[String::from("X")]);
259        let html = write_html(&grid);
260        let pre_open = html.find("<pre>").expect("opens with <pre>");
261        let pre_close = html.find("</pre>").expect("closes with </pre>");
262        assert!(pre_open < pre_close);
263    }
264}