1use crate::config::Config;
2use crate::message::BodyChain;
3use crate::quotes::{find_quote_class, is_sig_start, unquote};
4use crate::string_utils::conv_urls;
5
6const SAFE_IMAGE_TYPES: &[&str] = &[
9 "image/gif",
10 "image/jpeg",
11 "image/jpg",
12 "image/png",
13 "image/webp",
14 "image/bmp",
15 "image/tiff",
16];
17
18fn is_safe_base64(s: &str) -> bool {
21 !s.is_empty()
22 && s.bytes().all(|b| {
23 matches!(b,
24 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'+' | b'/' | b'='
25 )
26 })
27}
28
29fn try_inline_image_html(line: &str, pg_class: &str) -> Option<String> {
31 if !line.starts_with("[INLINE_IMAGE:") || !line.ends_with(']') {
32 return None;
33 }
34 let marker_content = line.strip_prefix("[INLINE_IMAGE:")?.strip_suffix(']')?;
35 let (mime_type, base64_data) = marker_content.split_once(':')?;
36 if !SAFE_IMAGE_TYPES.contains(&mime_type) || !is_safe_base64(base64_data) {
37 return None;
38 }
39 Some(format!(
40 "<div class=\"{}\"><img src=\"data:{};base64,{}\" alt=\"Embedded image\" style=\"max-width:100%;height:auto\"></div>\n",
41 pg_class, mime_type, base64_data
42 ))
43}
44
45fn txt2html_line(line: &str, config: &Config, in_sig: &mut bool) -> String {
46 let line = line.trim_end_matches('\n').trim_end_matches('\r');
47
48 if line.is_empty() {
49 return String::from("<div class=\"hm-blank\"></div>\n");
53 }
54
55 if is_sig_start(line) {
56 *in_sig = true;
57 return String::from("<hr class=\"hm-sig\">\n");
58 }
59
60 let pg_class = if *in_sig { "hm-sig-text" } else { "hm-pg" };
62
63 if let Some(html) = try_inline_image_html(line, pg_class) {
65 return html;
66 }
67
68 let is_quote = line.starts_with('>');
69 if is_quote {
70 let unquoted = unquote(line);
71 let quote_class = find_quote_class(line);
72 let escaped = escape_html(&unquoted);
73 let with_links = if config.href_detection {
74 conv_urls(&escaped)
75 } else {
76 escaped
77 };
78 format!("<div class=\"{}\">{}</div>\n", quote_class, with_links)
79 } else {
80 let escaped = escape_html(line);
81 let with_links = if config.href_detection {
82 conv_urls(&escaped)
83 } else {
84 escaped
85 };
86 format!("<div class=\"{}\">{}</div>\n", pg_class, with_links)
87 }
88}
89
90pub fn escape_html(s: &str) -> String {
91 let mut result = String::with_capacity(s.len());
92 for c in s.chars() {
93 match c {
94 '&' => result.push_str("&"),
95 '<' => result.push_str("<"),
96 '>' => result.push_str(">"),
97 '"' => result.push_str("""),
98 '\'' => result.push_str("'"),
99 '\t' => result.push_str(" "),
100 _ => result.push(c),
101 }
102 }
103 result
104}
105
106pub fn conv_showhtml(body: &mut BodyChain, config: &Config) {
107 let showhtml = config.showhtml;
108 let mut in_sig = false;
109
110 for b in &mut body.bodies {
111 if b.attached || b.header {
112 continue;
113 }
114
115 let trimmed = b.line.trim_end_matches('\n').trim_end_matches('\r');
117 if is_sig_start(trimmed) {
118 in_sig = true;
119 b.line = String::from("<hr class=\"hm-sig\">\n");
120 continue;
121 }
122
123 let pg_class = if in_sig { "hm-sig-text" } else { "hm-pg" };
124
125 if b.html {
126 if showhtml >= 2 {
127 continue;
128 }
129 if showhtml == 0 {
130 let escaped = escape_html(&b.line);
131 b.line = if escaped.is_empty() {
132 String::from("<div class=\"hm-blank\"></div>\n")
133 } else {
134 format!("<div class=\"{}\">{}</div>\n", pg_class, escaped)
135 };
136 continue;
137 }
138 if showhtml == 1 || showhtml >= 4 {
140 let escaped = escape_html(&b.line);
141 b.line = if escaped.is_empty() {
142 String::new()
143 } else {
144 format!("<div class=\"{}\">{}</div>\n", pg_class, escaped)
145 };
146 continue;
147 }
148 continue;
149 }
150 if showhtml == 0 {
153 let escaped = escape_html(&b.line);
154 b.line = if escaped.is_empty() {
155 String::from("<div class=\"hm-blank\"></div>\n")
156 } else {
157 format!("<div class=\"{}\">{}</div>\n", pg_class, escaped)
158 };
159 continue;
160 }
161 if showhtml == 1 {
162 let line = b.line.trim_end_matches('\n').trim_end_matches('\r');
164 if let Some(html) = try_inline_image_html(line, pg_class) {
165 b.line = html;
166 continue;
167 }
168
169 let escaped = escape_html(&b.line);
171 let content = if config.href_detection {
172 conv_urls(&escaped)
173 } else {
174 escaped
175 };
176 b.line = if content.is_empty() {
177 String::from("<div class=\"hm-blank\"></div>\n")
178 } else {
179 format!("<div class=\"{}\">{}</div>\n", pg_class, content)
180 };
181 continue;
182 }
183 if showhtml == 2 || showhtml == 3 {
184 b.line = txt2html_line(&b.line, config, &mut in_sig);
185 continue;
186 }
187 }
188}
189
190pub fn conv_body_line(line: &str, config: &Config) -> String {
191 if is_sig_start(line) {
192 return String::from("<hr class=\"hm-sig\">\n");
193 }
194
195 let is_quote = line.starts_with('>');
196 let escaped = escape_html(line);
197 let with_links = if config.href_detection {
198 conv_urls(&escaped)
199 } else {
200 escaped
201 };
202
203 if is_quote {
204 format!("<div class=\"{}\">{}</div>\n", find_quote_class_with_fallback(line), with_links)
205 } else {
206 format!("<div class=\"hm-pg\">{}</div>\n", with_links)
207 }
208}
209
210fn find_quote_class_with_fallback(line: &str) -> String {
211 let class = find_quote_class(line);
212 if class.is_empty() {
213 "hm-quote-1".to_string()
214 } else {
215 class
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use crate::config::Config;
223
224 fn make_config() -> Config {
225 Config::default()
226 }
227
228 #[test]
229 fn test_escape_html() {
230 assert_eq!(escape_html("<test>"), "<test>");
231 assert_eq!(escape_html("a&b"), "a&b");
232 assert_eq!(escape_html("hello"), "hello");
233 }
234
235 #[test]
236 fn test_txt2html_line_normal() {
237 let config = make_config();
238 let mut in_sig = false;
239 let result = txt2html_line("hello world", &config, &mut in_sig);
240 assert_eq!(result, "<div class=\"hm-pg\">hello world</div>\n");
241 }
242
243 #[test]
244 fn test_txt2html_line_quote() {
245 let config = make_config();
246 let mut in_sig = false;
247 let result = txt2html_line("> quoted text", &config, &mut in_sig);
248 assert!(result.contains("hm-quote-1"));
249 assert!(result.contains("quoted text"));
250 }
251
252 #[test]
253 fn test_txt2html_line_sig() {
254 let config = make_config();
255 let mut in_sig = false;
256 let result = txt2html_line("-- ", &config, &mut in_sig);
257 assert_eq!(result, "<hr class=\"hm-sig\">\n");
258 assert!(in_sig);
259 }
260
261 #[test]
262 fn test_txt2html_line_empty() {
263 let config = make_config();
264 let mut in_sig = false;
265 let result = txt2html_line("", &config, &mut in_sig);
266 assert_eq!(result, "<div class=\"hm-blank\"></div>\n");
267 }
268
269 #[test]
270 fn test_txt2html_line_urls() {
271 let config = make_config();
272 let mut in_sig = false;
273 let result = txt2html_line("Visit https://example.com", &config, &mut in_sig);
274 assert!(result.contains("<a href=\"https://example.com\""));
275 assert!(result.contains("rel=\"noopener noreferrer\""));
276 }
277
278 fn make_body_chain(text: &str) -> BodyChain {
279 let mut chain = BodyChain { bodies: Vec::new() };
280 chain.bodies.push(crate::message::Body {
281 line: text.to_string(),
282 html: false,
283 header: false,
284 parsed_header: false,
285 attached: false,
286 demimed: false,
287 msgnum: 1,
288 });
289 chain
290 }
291
292 #[test]
293 fn test_conv_showhtml_showhtml_0_escapes() {
294 let mut config = make_config();
295 config.showhtml = 0;
296 let mut chain = make_body_chain("<script>alert('xss')</script>");
297 conv_showhtml(&mut chain, &config);
298 assert_eq!(
300 chain.bodies[0].line,
301 "<div class=\"hm-pg\"><script>alert('xss')</script></div>\n"
302 );
303 }
304
305 #[test]
306 fn test_conv_showhtml_showhtml_0_plain_text() {
307 let mut config = make_config();
308 config.showhtml = 0;
309 let mut chain = make_body_chain("Hello World");
310 conv_showhtml(&mut chain, &config);
311 assert_eq!(chain.bodies[0].line, "<div class=\"hm-pg\">Hello World</div>\n");
312 }
313
314 #[test]
315 fn test_inline_image_rejects_attribute_breakout() {
316 let config = make_config();
317 let mut in_sig = false;
318 let line = r#"[INLINE_IMAGE:image/png:x" onerror="alert(1)]"#;
319 let result = txt2html_line(line, &config, &mut in_sig);
320 assert!(!result.contains("<img"), "invalid marker must not become img tag: {}", result);
322 assert!(
323 !result.contains(r#"onerror=""#) && !result.contains("onerror='"),
324 "must not create an onerror attribute: {}",
325 result
326 );
327 }
328
329 #[test]
330 fn test_inline_image_rejects_empty_base64() {
331 let config = make_config();
332 let mut in_sig = false;
333 let line = "[INLINE_IMAGE:image/png:]";
334 let result = txt2html_line(line, &config, &mut in_sig);
335 assert!(!result.contains("<img"));
336 }
337
338 #[test]
339 fn test_conv_showhtml_showhtml_1_wraps_in_div() {
340 let mut config = make_config();
341 config.showhtml = 1;
342 let mut chain = make_body_chain("Hello World");
343 conv_showhtml(&mut chain, &config);
344 assert_eq!(chain.bodies[0].line, "<div class=\"hm-pg\">Hello World</div>\n");
345 }
346
347 #[test]
348 fn test_conv_showhtml_showhtml_1_escapes_xss() {
349 let mut config = make_config();
350 config.showhtml = 1;
351 let mut chain = make_body_chain("<script>bad</script>");
352 conv_showhtml(&mut chain, &config);
353 assert_eq!(
354 chain.bodies[0].line,
355 "<div class=\"hm-pg\"><script>bad</script></div>\n"
356 );
357 }
358
359 #[test]
360 fn test_conv_showhtml_showhtml_2_txt2html() {
361 let mut config = make_config();
362 config.showhtml = 2;
363 let mut chain = make_body_chain("> quote");
364 conv_showhtml(&mut chain, &config);
365 assert!(chain.bodies[0].line.contains("hm-quote"));
366 }
367
368 #[test]
369 fn test_conv_showhtml_attached_unchanged() {
370 let mut config = make_config();
371 config.showhtml = 0;
372 let mut chain = BodyChain { bodies: Vec::new() };
373 chain.bodies.push(crate::message::Body {
374 line: "<script>attack</script>".to_string(),
375 html: false,
376 header: false,
377 parsed_header: false,
378 attached: true,
379 demimed: false,
380 msgnum: 1,
381 });
382 conv_showhtml(&mut chain, &config);
383 assert_eq!(chain.bodies[0].line, "<script>attack</script>");
385 }
386
387 #[test]
388 fn test_conv_showhtml_header_unchanged() {
389 let mut config = make_config();
390 config.showhtml = 0;
391 let mut chain = BodyChain { bodies: Vec::new() };
392 chain.bodies.push(crate::message::Body {
393 line: "<script>attack</script>".to_string(),
394 html: false,
395 header: true,
396 parsed_header: false,
397 attached: false,
398 demimed: false,
399 msgnum: 1,
400 });
401 conv_showhtml(&mut chain, &config);
402 assert_eq!(chain.bodies[0].line, "<script>attack</script>");
403 }
404
405 #[test]
406 fn test_txt2html_line_inline_image() {
407 let config = make_config();
408 let mut in_sig = false;
409 let line =
410 "[INLINE_IMAGE:image/gif:R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7]";
411 let result = txt2html_line(line, &config, &mut in_sig);
412
413 assert!(
415 result.contains("<img src=\"data:image/gif;base64,"),
416 "Should convert marker to img tag"
417 );
418 assert!(
419 result.contains("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"),
420 "Should contain base64 data"
421 );
422 assert!(result.contains("alt=\"Embedded image\""), "Should have alt text");
423 assert!(!result.contains("[INLINE_IMAGE:"), "Should not contain marker text");
424 }
425
426 #[test]
427 fn test_txt2html_line_inline_image_jpeg() {
428 let config = make_config();
429 let mut in_sig = false;
430 let line = "[INLINE_IMAGE:image/jpeg:abcd1234]";
431 let result = txt2html_line(line, &config, &mut in_sig);
432
433 assert!(
434 result.contains("<img src=\"data:image/jpeg;base64,abcd1234\""),
435 "Should create data URI with correct MIME type"
436 );
437 }
438
439 #[test]
440 fn test_txt2html_sig_then_body_uses_sig_class() {
441 let config = make_config();
442 let mut in_sig = false;
443 let _ = txt2html_line("-- ", &config, &mut in_sig);
444 assert!(in_sig);
445 let result = txt2html_line("John Doe", &config, &mut in_sig);
446 assert_eq!(result, "<div class=\"hm-sig-text\">John Doe</div>\n");
447 }
448
449 #[test]
450 fn test_conv_body_line_plain() {
451 let config = make_config();
452 let result = conv_body_line("Hello world", &config);
453 assert_eq!(result, "<div class=\"hm-pg\">Hello world</div>\n");
454 }
455
456 #[test]
457 fn test_conv_body_line_quote() {
458 let config = make_config();
459 let result = conv_body_line("> quoted", &config);
460 assert!(result.contains("hm-quote-1"));
461 assert!(result.contains("quoted"));
462 }
463
464 #[test]
465 fn test_conv_body_line_sig() {
466 let config = make_config();
467 let result = conv_body_line("-- ", &config);
468 assert_eq!(result, "<hr class=\"hm-sig\">\n");
469 }
470
471 #[test]
472 fn test_conv_body_line_escapes_html() {
473 let config = make_config();
474 let result = conv_body_line("<b>bold</b>", &config);
475 assert!(result.contains("<b>"));
476 assert!(!result.contains("<b>"));
477 }
478
479 #[test]
480 fn test_escape_html_quote() {
481 assert_eq!(escape_html("it's"), "it's");
482 }
483
484 #[test]
485 fn test_escape_html_double_quote() {
486 assert_eq!(escape_html(r#"say "hi""#), "say "hi"");
487 }
488
489 #[test]
490 fn test_escape_html_tab_expanded() {
491 let result = escape_html("a\tb");
492 assert!(result.contains(" ")); assert!(!result.contains('\t'));
494 }
495
496 #[test]
497 fn test_inline_image_svg_blocked() {
498 let config = make_config();
499 let mut in_sig = false;
500 let line = "[INLINE_IMAGE:image/svg+xml:PHN2Zy8+]";
501 let result = txt2html_line(line, &config, &mut in_sig);
502 assert!(!result.contains("<img"), "SVG should be blocked from inline embedding");
504 }
505}