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//! | `<` | `<` | text + attribute |
13//! | `>` | `>` | text + attribute |
14//! | `&` | `&` | text + attribute |
15//! | `"` | `"` | 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 → `<script>`).
31//! - Attribute-injection (`"` is escaped → `"><img onerror=x"` cannot
32//! close a surrounding attribute).
33//! - Double-encoding (`&` is escaped → `&` 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, "<>&"");
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, "<script>");
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("""));
153 assert!(s.contains("<img"));
154 }
155
156 #[test]
157 fn escape_ampersand_double_encoding() {
158 // Probe for & double-encoding behavior. `&` should become
159 // `&amp;` because each & is escaped exactly once.
160 let mut s = String::new();
161 escape_into(&mut s, "&");
162 assert_eq!(s, "&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 `<` exactly where the cell content
228 // sits — between `>` and `<` of the span tags.
229 assert!(html.contains("><</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}