1use std::fmt::Write;
19
20use chordsketch_core::ast::{CommentStyle, DirectiveKind, Line, LyricsLine, Song};
21use chordsketch_core::config::Config;
22use chordsketch_core::escape::escape_xml as escape;
23use chordsketch_core::inline_markup::{SpanAttributes, TextSpan};
24use chordsketch_core::render_result::RenderResult;
25use chordsketch_core::transpose::transpose_chord;
26
27const MAX_CHORUS_RECALLS: usize = 1000;
30
31const MAX_COLUMNS: u32 = 32;
34
35#[derive(Default, Clone)]
45struct ElementStyle {
46 font: Option<String>,
47 size: Option<String>,
48 colour: Option<String>,
49}
50
51impl ElementStyle {
52 fn to_css(&self) -> String {
57 let mut css = String::new();
58 if let Some(ref font) = self.font {
59 let _ = write!(css, "font-family: {};", sanitize_css_value(font));
60 }
61 if let Some(ref size) = self.size {
62 let safe = sanitize_css_value(size);
63 if safe.chars().all(|c| c.is_ascii_digit()) {
64 let _ = write!(css, "font-size: {safe}pt;");
65 } else {
66 let _ = write!(css, "font-size: {safe};");
67 }
68 }
69 if let Some(ref colour) = self.colour {
70 let _ = write!(css, "color: {};", sanitize_css_value(colour));
71 }
72 css
73 }
74}
75
76#[derive(Default, Clone)]
78struct FormattingState {
79 text: ElementStyle,
80 chord: ElementStyle,
81 tab: ElementStyle,
82 title: ElementStyle,
83 chorus: ElementStyle,
84 label: ElementStyle,
85 grid: ElementStyle,
86}
87
88impl FormattingState {
89 fn apply(&mut self, kind: &DirectiveKind, value: &Option<String>) {
91 let val = value.clone();
92 match kind {
93 DirectiveKind::TextFont => self.text.font = val,
94 DirectiveKind::TextSize => self.text.size = val,
95 DirectiveKind::TextColour => self.text.colour = val,
96 DirectiveKind::ChordFont => self.chord.font = val,
97 DirectiveKind::ChordSize => self.chord.size = val,
98 DirectiveKind::ChordColour => self.chord.colour = val,
99 DirectiveKind::TabFont => self.tab.font = val,
100 DirectiveKind::TabSize => self.tab.size = val,
101 DirectiveKind::TabColour => self.tab.colour = val,
102 DirectiveKind::TitleFont => self.title.font = val,
103 DirectiveKind::TitleSize => self.title.size = val,
104 DirectiveKind::TitleColour => self.title.colour = val,
105 DirectiveKind::ChorusFont => self.chorus.font = val,
106 DirectiveKind::ChorusSize => self.chorus.size = val,
107 DirectiveKind::ChorusColour => self.chorus.colour = val,
108 DirectiveKind::LabelFont => self.label.font = val,
109 DirectiveKind::LabelSize => self.label.size = val,
110 DirectiveKind::LabelColour => self.label.colour = val,
111 DirectiveKind::GridFont => self.grid.font = val,
112 DirectiveKind::GridSize => self.grid.size = val,
113 DirectiveKind::GridColour => self.grid.colour = val,
114 _ => {}
116 }
117 }
118}
119
120#[must_use]
129pub fn render_song(song: &Song) -> String {
130 render_song_with_transpose(song, 0, &Config::defaults())
131}
132
133#[must_use]
141pub fn render_song_with_transpose(song: &Song, cli_transpose: i8, config: &Config) -> String {
142 let result = render_song_with_warnings(song, cli_transpose, config);
143 for w in &result.warnings {
144 eprintln!("warning: {w}");
145 }
146 result.output
147}
148
149pub fn render_song_with_warnings(
155 song: &Song,
156 cli_transpose: i8,
157 config: &Config,
158) -> RenderResult<String> {
159 let mut warnings = Vec::new();
160 let title = song.metadata.title.as_deref().unwrap_or("Untitled");
161 let mut html = String::new();
162 let _ = write!(
163 html,
164 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n",
165 escape(title)
166 );
167 html.push_str("<style>\n");
168 html.push_str(CSS);
169 html.push_str("</style>\n</head>\n<body>\n");
170 render_song_body(song, cli_transpose, config, &mut html, &mut warnings);
171 html.push_str("</body>\n</html>\n");
172 RenderResult::with_warnings(html, warnings)
173}
174
175fn render_song_body(
181 song: &Song,
182 cli_transpose: i8,
183 config: &Config,
184 html: &mut String,
185 warnings: &mut Vec<String>,
186) {
187 let song_overrides = song.config_overrides();
189 let song_config;
190 let config = if song_overrides.is_empty() {
191 config
192 } else {
193 song_config = config
194 .clone()
195 .with_song_overrides(&song_overrides, warnings);
196 &song_config
197 };
198 let song_transpose_delta = Config::song_transpose_delta(&song_overrides);
201 let (combined_transpose, _) =
202 chordsketch_core::transpose::combine_transpose(cli_transpose, song_transpose_delta);
203 let mut transpose_offset: i8 = combined_transpose;
204 let mut fmt_state = FormattingState::default();
205 html.push_str("<div class=\"song\">\n");
206
207 render_metadata(&song.metadata, html);
208
209 let mut columns_open = false;
211 let mut svg_buf: Option<String> = None;
214 let mut abc2svg_resolved: Option<bool> = config.get_path("delegates.abc2svg").as_bool();
219 let mut lilypond_resolved: Option<bool> = config.get_path("delegates.lilypond").as_bool();
220 let mut abc_buf: Option<String> = None;
221 let mut abc_label: Option<String> = None;
222 let mut ly_buf: Option<String> = None;
223 let mut ly_label: Option<String> = None;
224
225 let mut show_diagrams = true;
227
228 let diagram_frets = config
230 .get_path("diagrams.frets")
231 .as_f64()
232 .map_or(chordsketch_core::chord_diagram::DEFAULT_FRETS_SHOWN, |n| {
233 (n as usize).max(1)
234 });
235
236 let mut chorus_body: Vec<Line> = Vec::new();
239 let mut chorus_buf: Option<Vec<Line>> = None;
241 let mut saved_fmt_state: Option<FormattingState> = None;
244 let mut chorus_recall_count: usize = 0;
245
246 for line in &song.lines {
247 match line {
248 Line::Lyrics(lyrics_line) => {
249 if let Some(ref mut buf) = svg_buf {
250 let raw = lyrics_line.text();
254 buf.push_str(&raw);
255 buf.push('\n');
256 } else if let Some(ref mut buf) = abc_buf {
257 let raw = lyrics_line.text();
259 buf.push_str(&raw);
260 buf.push('\n');
261 } else if let Some(ref mut buf) = ly_buf {
262 let raw = lyrics_line.text();
264 buf.push_str(&raw);
265 buf.push('\n');
266 } else {
267 if let Some(buf) = chorus_buf.as_mut() {
268 buf.push(line.clone());
269 }
270 render_lyrics(lyrics_line, transpose_offset, &fmt_state, html);
271 }
272 }
273 Line::Directive(directive) => {
274 if directive.kind.is_metadata() {
275 continue;
276 }
277 if directive.kind == DirectiveKind::Diagrams {
278 show_diagrams = !directive
279 .value
280 .as_deref()
281 .is_some_and(|v| v.eq_ignore_ascii_case("off"));
282 continue;
283 }
284 if directive.kind == DirectiveKind::Transpose {
285 let file_offset: i8 = directive
286 .value
287 .as_deref()
288 .and_then(|v| v.parse().ok())
289 .unwrap_or(0);
290 let (combined, saturated) =
291 chordsketch_core::transpose::combine_transpose(file_offset, cli_transpose);
292 if saturated {
293 warnings.push(format!(
294 "transpose offset {file_offset} + {cli_transpose} \
295 exceeds i8 range, clamped to {combined}"
296 ));
297 }
298 transpose_offset = combined;
299 continue;
300 }
301 if directive.kind.is_font_size_color() {
302 if let Some(buf) = chorus_buf.as_mut() {
303 buf.push(line.clone());
304 }
305 fmt_state.apply(&directive.kind, &directive.value);
306 continue;
307 }
308 match &directive.kind {
309 DirectiveKind::StartOfChorus => {
310 render_section_open("chorus", "Chorus", &directive.value, html);
311 chorus_buf = Some(Vec::new());
312 saved_fmt_state = Some(fmt_state.clone());
315 }
316 DirectiveKind::EndOfChorus => {
317 html.push_str("</section>\n");
318 if let Some(buf) = chorus_buf.take() {
319 chorus_body = buf;
320 }
321 if let Some(saved) = saved_fmt_state.take() {
323 fmt_state = saved;
324 }
325 }
326 DirectiveKind::Chorus => {
327 if chorus_recall_count < MAX_CHORUS_RECALLS {
328 render_chorus_recall(
329 &directive.value,
330 &chorus_body,
331 transpose_offset,
332 &fmt_state,
333 show_diagrams,
334 diagram_frets,
335 html,
336 );
337 chorus_recall_count += 1;
338 } else if chorus_recall_count == MAX_CHORUS_RECALLS {
339 warnings.push(format!(
340 "chorus recall limit ({MAX_CHORUS_RECALLS}) reached, \
341 further recalls suppressed"
342 ));
343 chorus_recall_count += 1;
344 }
345 }
346 DirectiveKind::Columns => {
347 let n: u32 = directive
351 .value
352 .as_deref()
353 .and_then(|v| v.trim().parse().ok())
354 .unwrap_or(1)
355 .clamp(1, MAX_COLUMNS);
356 if columns_open {
357 html.push_str("</div>\n");
358 columns_open = false;
359 }
360 if n > 1 {
361 let _ = writeln!(
362 html,
363 "<div style=\"column-count: {n};column-gap: 2em;\">"
364 );
365 columns_open = true;
366 }
367 }
368 DirectiveKind::ColumnBreak => {
374 html.push_str("<div style=\"break-before: column;\"></div>\n");
375 }
376 DirectiveKind::NewPage => {
377 html.push_str("<div style=\"break-before: page;\"></div>\n");
378 }
379 DirectiveKind::NewPhysicalPage => {
380 html.push_str("<div style=\"break-before: recto;\"></div>\n");
384 }
385 DirectiveKind::StartOfAbc => {
386 let enabled = *abc2svg_resolved
387 .get_or_insert_with(chordsketch_core::external_tool::has_abc2svg);
388 if enabled {
389 abc_buf = Some(String::new());
390 abc_label = directive.value.clone();
391 } else {
392 if let Some(buf) = chorus_buf.as_mut() {
393 buf.push(line.clone());
394 }
395 render_directive_inner(directive, show_diagrams, diagram_frets, html);
396 }
397 }
398 DirectiveKind::EndOfAbc if abc_buf.is_some() => {
399 if let Some(abc_content) = abc_buf.take() {
400 render_abc_with_fallback(&abc_content, &abc_label, html, warnings);
401 abc_label = None;
402 }
403 }
404 DirectiveKind::StartOfLy => {
405 let enabled = *lilypond_resolved
406 .get_or_insert_with(chordsketch_core::external_tool::has_lilypond);
407 if enabled {
408 ly_buf = Some(String::new());
409 ly_label = directive.value.clone();
410 } else {
411 if let Some(buf) = chorus_buf.as_mut() {
412 buf.push(line.clone());
413 }
414 render_directive_inner(directive, show_diagrams, diagram_frets, html);
415 }
416 }
417 DirectiveKind::EndOfLy if ly_buf.is_some() => {
418 if let Some(ly_content) = ly_buf.take() {
419 render_ly_with_fallback(&ly_content, &ly_label, html, warnings);
420 ly_label = None;
421 }
422 }
423 DirectiveKind::StartOfSvg => {
424 svg_buf = Some(String::new());
425 }
426 DirectiveKind::EndOfSvg if svg_buf.is_some() => {
427 if let Some(svg_content) = svg_buf.take() {
428 html.push_str("<div class=\"svg-section\">\n");
429 html.push_str(&sanitize_svg_content(&svg_content));
430 html.push('\n');
431 html.push_str("</div>\n");
432 }
433 }
434 _ => {
435 if let Some(buf) = chorus_buf.as_mut() {
436 buf.push(line.clone());
437 }
438 render_directive_inner(directive, show_diagrams, diagram_frets, html);
439 }
440 }
441 }
442 Line::Comment(style, text) => {
443 if let Some(buf) = chorus_buf.as_mut() {
444 buf.push(line.clone());
445 }
446 render_comment(*style, text, html);
447 }
448 Line::Empty => {
449 if let Some(buf) = chorus_buf.as_mut() {
450 buf.push(line.clone());
451 }
452 html.push_str("<div class=\"empty-line\"></div>\n");
453 }
454 }
455 }
456
457 if columns_open {
459 html.push_str("</div>\n");
460 }
461
462 html.push_str("</div>\n");
463}
464
465#[must_use]
467pub fn render_songs(songs: &[Song]) -> String {
468 render_songs_with_transpose(songs, 0, &Config::defaults())
469}
470
471#[must_use]
480pub fn render_songs_with_transpose(songs: &[Song], cli_transpose: i8, config: &Config) -> String {
481 let result = render_songs_with_warnings(songs, cli_transpose, config);
482 for w in &result.warnings {
483 eprintln!("warning: {w}");
484 }
485 result.output
486}
487
488pub fn render_songs_with_warnings(
495 songs: &[Song],
496 cli_transpose: i8,
497 config: &Config,
498) -> RenderResult<String> {
499 let mut warnings = Vec::new();
500 if songs.len() <= 1 {
501 let output = songs
502 .first()
503 .map(|s| {
504 let r = render_song_with_warnings(s, cli_transpose, config);
505 warnings = r.warnings;
506 r.output
507 })
508 .unwrap_or_default();
509 return RenderResult::with_warnings(output, warnings);
510 }
511 let mut html = String::new();
513 let title = songs
514 .first()
515 .and_then(|s| s.metadata.title.as_deref())
516 .unwrap_or("Untitled");
517 let _ = write!(
518 html,
519 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n",
520 escape(title)
521 );
522 html.push_str("<style>\n");
523 html.push_str(CSS);
524 html.push_str("</style>\n</head>\n<body>\n");
525
526 for (i, song) in songs.iter().enumerate() {
527 if i > 0 {
528 html.push_str("<hr class=\"song-separator\">\n");
529 }
530 render_song_body(song, cli_transpose, config, &mut html, &mut warnings);
531 }
532
533 html.push_str("</body>\n</html>\n");
534 RenderResult::with_warnings(html, warnings)
535}
536
537#[must_use = "parse errors should be handled"]
542pub fn try_render(input: &str) -> Result<String, chordsketch_core::ParseError> {
543 let song = chordsketch_core::parse(input)?;
544 Ok(render_song(&song))
545}
546
547#[must_use]
552pub fn render(input: &str) -> String {
553 match try_render(input) {
554 Ok(html) => html,
555 Err(e) => format!(
556 "<!DOCTYPE html><html><body><pre>Parse error at line {} column {}: {}</pre></body></html>\n",
557 e.line(),
558 e.column(),
559 escape(&e.message)
560 ),
561 }
562}
563
564const CSS: &str = "\
570body { font-family: serif; max-width: 800px; margin: 2em auto; padding: 0 1em; }
571h1 { margin-bottom: 0.2em; }
572h2 { margin-top: 0; font-weight: normal; color: #555; }
573.line { display: flex; flex-wrap: wrap; margin: 0.1em 0; }
574.chord-block { display: inline-flex; flex-direction: column; align-items: flex-start; }
575.chord { font-weight: bold; color: #b00; font-size: 0.9em; min-height: 1.2em; }
576.lyrics { white-space: pre; }
577.empty-line { height: 1em; }
578section { margin: 1em 0; }
579section > .section-label { font-weight: bold; font-style: italic; margin-bottom: 0.3em; }
580.comment { font-style: italic; color: #666; margin: 0.3em 0; }
581.comment-box { border: 1px solid #999; padding: 0.2em 0.5em; display: inline-block; margin: 0.3em 0; }
582.chorus-recall { margin: 1em 0; }
583.chorus-recall > .section-label { font-weight: bold; font-style: italic; margin-bottom: 0.3em; }
584img { max-width: 100%; height: auto; }
585.chord-diagram-container { display: inline-block; margin: 0.5em 0.5em 0.5em 0; vertical-align: top; }
586.chord-diagram { display: block; }
587";
588
589fn render_metadata(metadata: &chordsketch_core::ast::Metadata, html: &mut String) {
599 if let Some(title) = &metadata.title {
600 let _ = writeln!(html, "<h1>{}</h1>", escape(title));
601 }
602 for subtitle in &metadata.subtitles {
603 let _ = writeln!(html, "<h2>{}</h2>", escape(subtitle));
604 }
605}
606
607fn render_lyrics(
617 lyrics_line: &LyricsLine,
618 transpose_offset: i8,
619 fmt_state: &FormattingState,
620 html: &mut String,
621) {
622 html.push_str("<div class=\"line\">");
623
624 for segment in &lyrics_line.segments {
625 html.push_str("<span class=\"chord-block\">");
626
627 if let Some(chord) = &segment.chord {
628 let display_name = if transpose_offset != 0 {
629 let transposed = transpose_chord(chord, transpose_offset);
630 transposed.display_name().to_string()
631 } else {
632 chord.display_name().to_string()
633 };
634 let chord_css = fmt_state.chord.to_css();
635 if chord_css.is_empty() {
636 let _ = write!(
637 html,
638 "<span class=\"chord\">{}</span>",
639 escape(&display_name)
640 );
641 } else {
642 let _ = write!(
643 html,
644 "<span class=\"chord\" style=\"{}\">{}</span>",
645 escape(&chord_css),
646 escape(&display_name)
647 );
648 }
649 } else if lyrics_line.has_chords() {
650 html.push_str("<span class=\"chord\"></span>");
652 }
653
654 let text_css = fmt_state.text.to_css();
655 if text_css.is_empty() {
656 html.push_str("<span class=\"lyrics\">");
657 } else {
658 let _ = write!(
659 html,
660 "<span class=\"lyrics\" style=\"{}\">",
661 escape(&text_css)
662 );
663 }
664 if segment.has_markup() {
665 render_spans(&segment.spans, html);
666 } else {
667 html.push_str(&escape(&segment.text));
668 }
669 html.push_str("</span>");
670 html.push_str("</span>");
671 }
672
673 html.push_str("</div>\n");
674}
675
676fn render_spans(spans: &[TextSpan], html: &mut String) {
685 for span in spans {
686 match span {
687 TextSpan::Plain(text) => html.push_str(&escape(text)),
688 TextSpan::Bold(children) => {
689 html.push_str("<b>");
690 render_spans(children, html);
691 html.push_str("</b>");
692 }
693 TextSpan::Italic(children) => {
694 html.push_str("<i>");
695 render_spans(children, html);
696 html.push_str("</i>");
697 }
698 TextSpan::Highlight(children) => {
699 html.push_str("<mark>");
700 render_spans(children, html);
701 html.push_str("</mark>");
702 }
703 TextSpan::Comment(children) => {
704 html.push_str("<span class=\"comment\">");
705 render_spans(children, html);
706 html.push_str("</span>");
707 }
708 TextSpan::Span(attrs, children) => {
709 let css = span_attrs_to_css(attrs);
710 if css.is_empty() {
711 html.push_str("<span>");
712 } else {
713 let _ = write!(html, "<span style=\"{}\">", escape(&css));
714 }
715 render_spans(children, html);
716 html.push_str("</span>");
717 }
718 }
719 }
720}
721
722fn span_attrs_to_css(attrs: &SpanAttributes) -> String {
724 let mut css = String::new();
725 if let Some(ref font_family) = attrs.font_family {
726 let _ = write!(css, "font-family: {};", sanitize_css_value(font_family));
727 }
728 if let Some(ref size) = attrs.size {
729 let safe = sanitize_css_value(size);
730 if safe.chars().all(|c| c.is_ascii_digit()) {
732 let _ = write!(css, "font-size: {safe}pt;");
733 } else {
734 let _ = write!(css, "font-size: {safe};");
735 }
736 }
737 if let Some(ref fg) = attrs.foreground {
738 let _ = write!(css, "color: {};", sanitize_css_value(fg));
739 }
740 if let Some(ref bg) = attrs.background {
741 let _ = write!(css, "background-color: {};", sanitize_css_value(bg));
742 }
743 if let Some(ref weight) = attrs.weight {
744 let _ = write!(css, "font-weight: {};", sanitize_css_value(weight));
745 }
746 if let Some(ref style) = attrs.style {
747 let _ = write!(css, "font-style: {};", sanitize_css_value(style));
748 }
749 css
750}
751
752fn sanitize_css_value(s: &str) -> String {
759 s.chars()
760 .filter(|c| {
761 c.is_ascii_alphanumeric() || matches!(c, '#' | '.' | '-' | ' ' | ',' | '%' | '+')
762 })
763 .collect()
764}
765
766fn sanitize_css_class(s: &str) -> String {
773 s.chars()
774 .map(|c| {
775 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
776 c
777 } else {
778 '-'
779 }
780 })
781 .collect()
782}
783
784fn sanitize_svg_content(input: &str) -> String {
791 const DANGEROUS_TAGS: &[&str] = &[
793 "script",
794 "foreignobject",
795 "iframe",
796 "object",
797 "embed",
798 "math",
799 "set",
800 "animate",
801 "animatetransform",
802 "animatemotion",
803 ];
804
805 let mut result = String::with_capacity(input.len());
806 let mut chars = input.char_indices().peekable();
807 let bytes = input.as_bytes();
808
809 while let Some((i, c)) = chars.next() {
810 if c == '<' {
811 let rest = &input[i..];
812 let limit = rest
815 .char_indices()
816 .map(|(idx, _)| idx)
817 .find(|&idx| idx >= 30)
818 .unwrap_or(rest.len());
819 let rest_upper = &rest[..limit];
820
821 let mut matched = false;
823 for tag in DANGEROUS_TAGS {
824 let prefix = format!("<{tag}");
825 if starts_with_ignore_case(rest_upper, &prefix)
826 && rest.len() > prefix.len()
827 && bytes
828 .get(i + prefix.len())
829 .is_none_or(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/')
830 {
831 let is_self_closing = {
835 let tag_bytes = rest.as_bytes();
836 let mut in_quote: Option<u8> = None;
837 let mut gt_pos = None;
838 for (idx, &b) in tag_bytes.iter().enumerate() {
839 match in_quote {
840 Some(q) if b == q => in_quote = None,
841 Some(_) => {}
842 None if b == b'"' || b == b'\'' => in_quote = Some(b),
843 None if b == b'>' => {
844 gt_pos = Some(idx);
845 break;
846 }
847 _ => {}
848 }
849 }
850 gt_pos.is_some_and(|gt| gt > 0 && tag_bytes[gt - 1] == b'/')
851 };
852
853 if is_self_closing {
854 let mut skip_quote: Option<char> = None;
858 while let Some(&(_, ch)) = chars.peek() {
859 chars.next();
860 match skip_quote {
861 Some(q) if ch == q => skip_quote = None,
862 Some(_) => {}
863 None if ch == '"' || ch == '\'' => {
864 skip_quote = Some(ch);
865 }
866 None if ch == '>' => break,
867 _ => {}
868 }
869 }
870 } else if let Some(end) = find_end_tag_ignore_case(input, i, tag) {
871 while let Some(&(j, _)) = chars.peek() {
873 if j >= end {
874 break;
875 }
876 chars.next();
877 }
878 } else {
879 return result;
881 }
882 matched = true;
883 break;
884 }
885 }
886 if matched {
887 continue;
888 }
889
890 for tag in DANGEROUS_TAGS {
892 let prefix = format!("</{tag}");
893 if starts_with_ignore_case(rest_upper, &prefix)
894 && rest.len() > prefix.len()
895 && bytes
896 .get(i + prefix.len())
897 .is_none_or(|b| b.is_ascii_whitespace() || *b == b'>')
898 {
899 while let Some(&(_, ch)) = chars.peek() {
901 chars.next();
902 if ch == '>' {
903 break;
904 }
905 }
906 matched = true;
907 break;
908 }
909 }
910 if matched {
911 continue;
912 }
913
914 result.push(c);
915 } else {
916 result.push(c);
917 }
918 }
919
920 strip_dangerous_attrs(&result)
922}
923
924fn starts_with_ignore_case(s: &str, prefix: &str) -> bool {
926 if s.len() < prefix.len() {
927 return false;
928 }
929 s.as_bytes()[..prefix.len()]
930 .iter()
931 .zip(prefix.as_bytes())
932 .all(|(a, b)| a.eq_ignore_ascii_case(b))
933}
934
935fn find_end_tag_ignore_case(input: &str, start: usize, tag: &str) -> Option<usize> {
938 let search = &input.as_bytes()[start..];
939 let tag_bytes = tag.as_bytes();
940 let close_prefix_len = 2 + tag_bytes.len(); for i in 0..search.len() {
943 if search[i] == b'<'
944 && i + 1 < search.len()
945 && search[i + 1] == b'/'
946 && i + close_prefix_len <= search.len()
947 {
948 let candidate = &search[i + 2..i + close_prefix_len];
949 if candidate
950 .iter()
951 .zip(tag_bytes)
952 .all(|(a, b)| a.eq_ignore_ascii_case(b))
953 {
954 if let Some(gt) = search[i + close_prefix_len..]
956 .iter()
957 .position(|&b| b == b'>')
958 {
959 return Some(start + i + close_prefix_len + gt + 1);
960 }
961 }
962 }
963 }
964 None
965}
966
967fn strip_dangerous_attrs(input: &str) -> String {
972 let mut result = String::with_capacity(input.len());
973 let bytes = input.as_bytes();
974 let mut pos = 0;
975
976 while pos < bytes.len() {
977 if bytes[pos] == b'<' && pos + 1 < bytes.len() && bytes[pos + 1] != b'/' {
978 if let Some(gt) = find_tag_end(&bytes[pos..]) {
982 let tag_end = pos + gt + 1;
983 let tag_content = &input[pos..tag_end];
984 result.push_str(&sanitize_tag_attrs(tag_content));
985 pos = tag_end;
986 } else {
987 result.push_str(&input[pos..]);
988 break;
989 }
990 } else {
991 let ch = &input[pos..];
994 let c = ch.chars().next().expect("pos is within bounds");
995 result.push(c);
996 pos += c.len_utf8();
997 }
998 }
999 result
1000}
1001
1002fn find_tag_end(bytes: &[u8]) -> Option<usize> {
1005 let mut i = 0;
1006 let mut in_quote: Option<u8> = None;
1007 while i < bytes.len() {
1008 let b = bytes[i];
1009 if let Some(q) = in_quote {
1010 if b == q {
1011 in_quote = None;
1012 }
1013 } else if b == b'"' || b == b'\'' {
1014 in_quote = Some(b);
1015 } else if b == b'>' {
1016 return Some(i);
1017 }
1018 i += 1;
1019 }
1020 None
1021}
1022
1023fn has_dangerous_uri_scheme(value: &str) -> bool {
1026 let lower: String = value
1032 .trim_start()
1033 .chars()
1034 .filter(|c| !c.is_ascii_whitespace() && !c.is_ascii_control())
1035 .take(30)
1036 .flat_map(|c| c.to_lowercase())
1037 .collect();
1038 lower.starts_with("javascript:") || lower.starts_with("vbscript:") || lower.starts_with("data:")
1039}
1040
1041fn is_uri_attr(name: &str) -> bool {
1044 let lower: String = name.chars().flat_map(|c| c.to_lowercase()).collect();
1045 lower == "href"
1046 || lower == "src"
1047 || lower == "xlink:href"
1048 || lower == "to"
1049 || lower == "values"
1050 || lower == "from"
1051 || lower == "by"
1052}
1053
1054fn sanitize_tag_attrs(tag: &str) -> String {
1065 let mut result = String::with_capacity(tag.len());
1066 let bytes = tag.as_bytes();
1067 let mut i = 0;
1068
1069 while i < bytes.len() && bytes[i] != b' ' && bytes[i] != b'>' && bytes[i] != b'/' {
1071 result.push(bytes[i] as char);
1072 i += 1;
1073 }
1074
1075 while i < bytes.len() {
1076 while i < bytes.len() && bytes[i].is_ascii_whitespace() {
1078 result.push(bytes[i] as char);
1079 i += 1;
1080 }
1081
1082 if i >= bytes.len() || bytes[i] == b'>' || bytes[i] == b'/' {
1083 result.push_str(&tag[i..]);
1084 return result;
1085 }
1086
1087 let attr_start = i;
1089 while i < bytes.len()
1090 && bytes[i] != b'='
1091 && bytes[i] != b' '
1092 && bytes[i] != b'>'
1093 && bytes[i] != b'/'
1094 {
1095 i += 1;
1096 }
1097 let attr_name = &tag[attr_start..i];
1098
1099 let is_event_handler = attr_name.len() > 2
1100 && attr_name.as_bytes()[..2].eq_ignore_ascii_case(b"on")
1101 && attr_name.as_bytes()[2].is_ascii_alphabetic();
1102
1103 let value_start = i;
1105 let mut attr_value: Option<String> = None;
1106 if i < bytes.len() && bytes[i] == b'=' {
1107 i += 1; if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') {
1109 let quote = bytes[i];
1110 i += 1;
1111 let val_start = i;
1112 while i < bytes.len() && bytes[i] != quote {
1113 i += 1;
1114 }
1115 attr_value = Some(tag[val_start..i].to_string());
1116 if i < bytes.len() {
1117 i += 1; }
1119 } else {
1120 let val_start = i;
1122 while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>' {
1123 i += 1;
1124 }
1125 attr_value = Some(tag[val_start..i].to_string());
1126 }
1127 }
1128
1129 if is_event_handler {
1130 continue;
1132 }
1133
1134 if is_uri_attr(attr_name) {
1135 if let Some(ref val) = attr_value {
1136 if has_dangerous_uri_scheme(val) {
1137 continue;
1139 }
1140 }
1141 }
1142
1143 if attr_name.eq_ignore_ascii_case("style") {
1146 if let Some(ref val) = attr_value {
1147 let lower_val: String = val.chars().flat_map(|c| c.to_lowercase()).collect();
1148 if lower_val.contains("url(")
1149 || lower_val.contains("expression(")
1150 || lower_val.contains("@import")
1151 {
1152 continue;
1153 }
1154 }
1155 }
1156
1157 result.push_str(&tag[attr_start..value_start]);
1159 if attr_value.is_some() {
1160 result.push_str(&tag[value_start..i]);
1161 }
1162 }
1163
1164 result
1165}
1166
1167fn render_directive_inner(
1176 directive: &chordsketch_core::ast::Directive,
1177 show_diagrams: bool,
1178 diagram_frets: usize,
1179 html: &mut String,
1180) {
1181 match &directive.kind {
1182 DirectiveKind::StartOfChorus => {
1183 render_section_open("chorus", "Chorus", &directive.value, html);
1184 }
1185 DirectiveKind::StartOfVerse => {
1186 render_section_open("verse", "Verse", &directive.value, html);
1187 }
1188 DirectiveKind::StartOfBridge => {
1189 render_section_open("bridge", "Bridge", &directive.value, html);
1190 }
1191 DirectiveKind::StartOfTab => {
1192 render_section_open("tab", "Tab", &directive.value, html);
1193 }
1194 DirectiveKind::StartOfGrid => {
1195 render_section_open("grid", "Grid", &directive.value, html);
1196 }
1197 DirectiveKind::StartOfAbc => {
1198 render_section_open("abc", "ABC", &directive.value, html);
1199 }
1200 DirectiveKind::StartOfLy => {
1201 render_section_open("ly", "Lilypond", &directive.value, html);
1202 }
1203 DirectiveKind::StartOfTextblock => {
1206 render_section_open("textblock", "Textblock", &directive.value, html);
1207 }
1208 DirectiveKind::StartOfSection(section_name) => {
1209 let class = format!("section-{}", sanitize_css_class(section_name));
1210 let label = escape(&chordsketch_core::capitalize(section_name));
1211 render_section_open(&class, &label, &directive.value, html);
1212 }
1213 DirectiveKind::EndOfChorus
1214 | DirectiveKind::EndOfVerse
1215 | DirectiveKind::EndOfBridge
1216 | DirectiveKind::EndOfTab
1217 | DirectiveKind::EndOfGrid
1218 | DirectiveKind::EndOfAbc
1219 | DirectiveKind::EndOfLy
1220 | DirectiveKind::EndOfSvg
1221 | DirectiveKind::EndOfTextblock
1222 | DirectiveKind::EndOfSection(_) => {
1223 html.push_str("</section>\n");
1224 }
1225 DirectiveKind::Image(attrs) => {
1226 render_image(attrs, html);
1227 }
1228 DirectiveKind::Define => {
1229 if show_diagrams {
1230 if let Some(ref value) = directive.value {
1231 let def = chordsketch_core::ast::ChordDefinition::parse_value(value);
1232 if let Some(ref raw) = def.raw {
1233 if let Some(mut diagram) =
1234 chordsketch_core::chord_diagram::DiagramData::from_raw_infer_frets(
1235 &def.name,
1236 raw,
1237 diagram_frets,
1238 )
1239 {
1240 diagram.display_name = def.display.clone();
1241 html.push_str("<div class=\"chord-diagram-container\">");
1242 html.push_str(&chordsketch_core::chord_diagram::render_svg(&diagram));
1243 html.push_str("</div>\n");
1244 }
1245 }
1246 }
1247 }
1248 }
1249 _ => {}
1250 }
1251}
1252
1253fn render_abc_with_fallback(
1259 abc_content: &str,
1260 label: &Option<String>,
1261 html: &mut String,
1262 warnings: &mut Vec<String>,
1263) {
1264 match chordsketch_core::external_tool::invoke_abc2svg(abc_content) {
1265 Ok(svg_fragment) => {
1266 render_section_open("abc", "ABC", label, html);
1267 html.push_str(&sanitize_svg_content(&svg_fragment));
1268 html.push('\n');
1269 html.push_str("</section>\n");
1270 }
1271 Err(e) => {
1272 warnings.push(format!("abc2svg invocation failed: {e}"));
1273 render_section_open("abc", "ABC", label, html);
1274 html.push_str("<pre>");
1275 html.push_str(&escape(abc_content));
1276 html.push_str("</pre>\n");
1277 html.push_str("</section>\n");
1278 }
1279 }
1280}
1281
1282fn is_safe_image_src(src: &str) -> bool {
1290 if src.is_empty() {
1291 return false;
1292 }
1293
1294 if src.contains('\0') {
1296 return false;
1297 }
1298
1299 let normalised = src.trim_start().to_ascii_lowercase();
1302
1303 if normalised.starts_with('/') {
1306 return false;
1307 }
1308
1309 if is_windows_absolute(src.trim_start()) {
1311 return false;
1312 }
1313
1314 if has_traversal(src) {
1316 return false;
1317 }
1318
1319 if let Some(colon_pos) = normalised.find(':') {
1322 let before_colon = &normalised[..colon_pos];
1323 if !before_colon.contains('/') {
1325 return before_colon == "http" || before_colon == "https";
1326 }
1327 }
1328
1329 true
1330}
1331
1332use chordsketch_core::image_path::{has_traversal, is_windows_absolute};
1334
1335fn render_ly_with_fallback(
1341 ly_content: &str,
1342 label: &Option<String>,
1343 html: &mut String,
1344 warnings: &mut Vec<String>,
1345) {
1346 match chordsketch_core::external_tool::invoke_lilypond(ly_content) {
1347 Ok(svg) => {
1348 render_section_open("ly", "Lilypond", label, html);
1349 html.push_str(&sanitize_svg_content(&svg));
1350 html.push('\n');
1351 html.push_str("</section>\n");
1352 }
1353 Err(e) => {
1354 warnings.push(format!("lilypond invocation failed: {e}"));
1355 render_section_open("ly", "Lilypond", label, html);
1356 html.push_str("<pre>");
1357 html.push_str(&escape(ly_content));
1358 html.push_str("</pre>\n");
1359 html.push_str("</section>\n");
1360 }
1361 }
1362}
1363
1364fn render_image(attrs: &chordsketch_core::ast::ImageAttributes, html: &mut String) {
1373 if !is_safe_image_src(&attrs.src) {
1374 return;
1375 }
1376
1377 let mut style = String::new();
1378 let mut img_attrs = format!("src=\"{}\"", escape(&attrs.src));
1379
1380 if let Some(ref title) = attrs.title {
1381 let _ = write!(img_attrs, " alt=\"{}\"", escape(title));
1382 }
1383
1384 if let Some(ref width) = attrs.width {
1385 let _ = write!(img_attrs, " width=\"{}\"", escape(width));
1386 }
1387 if let Some(ref height) = attrs.height {
1388 let _ = write!(img_attrs, " height=\"{}\"", escape(height));
1389 }
1390 if let Some(ref scale) = attrs.scale {
1391 let _ = write!(
1393 style,
1394 "transform: scale({});transform-origin: top left;",
1395 sanitize_css_value(scale)
1396 );
1397 }
1398
1399 let align_css = match attrs.anchor.as_deref() {
1401 Some("column") | Some("paper") => "text-align: center;",
1402 _ => "",
1403 };
1404
1405 if !align_css.is_empty() {
1406 let _ = write!(html, "<div style=\"{align_css}\">");
1407 } else {
1408 html.push_str("<div>");
1409 }
1410
1411 let _ = write!(html, "<img {img_attrs}");
1412 if !style.is_empty() {
1413 let _ = write!(html, " style=\"{}\"", escape(&style));
1419 }
1420 html.push_str("></div>\n");
1421}
1422
1423fn render_section_open(class: &str, label: &str, value: &Option<String>, html: &mut String) {
1425 let safe_class = sanitize_css_class(class);
1426 let _ = writeln!(html, "<section class=\"{safe_class}\">");
1427 let display_label = match value {
1428 Some(v) if !v.is_empty() => format!("{label}: {}", escape(v)),
1429 _ => label.to_string(),
1430 };
1431 let _ = writeln!(html, "<div class=\"section-label\">{display_label}</div>");
1432}
1433
1434fn render_chorus_recall(
1440 value: &Option<String>,
1441 chorus_body: &[Line],
1442 transpose_offset: i8,
1443 fmt_state: &FormattingState,
1444 show_diagrams: bool,
1445 diagram_frets: usize,
1446 html: &mut String,
1447) {
1448 html.push_str("<div class=\"chorus-recall\">\n");
1449 let display_label = match value {
1450 Some(v) if !v.is_empty() => format!("Chorus: {}", escape(v)),
1451 _ => "Chorus".to_string(),
1452 };
1453 let _ = writeln!(html, "<div class=\"section-label\">{display_label}</div>");
1454 let mut local_fmt = fmt_state.clone();
1458 for line in chorus_body {
1459 match line {
1460 Line::Lyrics(lyrics) => render_lyrics(lyrics, transpose_offset, &local_fmt, html),
1461 Line::Comment(style, text) => render_comment(*style, text, html),
1462 Line::Empty => html.push_str("<div class=\"empty-line\"></div>\n"),
1463 Line::Directive(d) if d.kind.is_font_size_color() => {
1464 local_fmt.apply(&d.kind, &d.value);
1465 }
1466 Line::Directive(d) if !d.kind.is_metadata() => {
1467 render_directive_inner(d, show_diagrams, diagram_frets, html);
1468 }
1469 _ => {}
1470 }
1471 }
1472 html.push_str("</div>\n");
1473}
1474
1475fn render_comment(style: CommentStyle, text: &str, html: &mut String) {
1481 match style {
1482 CommentStyle::Normal => {
1483 let _ = writeln!(html, "<p class=\"comment\">{}</p>", escape(text));
1484 }
1485 CommentStyle::Italic => {
1486 let _ = writeln!(html, "<p class=\"comment\"><em>{}</em></p>", escape(text));
1487 }
1488 CommentStyle::Boxed => {
1489 let _ = writeln!(html, "<div class=\"comment-box\">{}</div>", escape(text));
1490 }
1491 }
1492}
1493
1494#[cfg(test)]
1499mod sanitize_tag_attrs_tests {
1500 use super::*;
1501
1502 #[test]
1503 fn test_preserves_normal_attrs() {
1504 let tag = "<svg width=\"100\" height=\"50\">";
1505 assert_eq!(sanitize_tag_attrs(tag), tag);
1506 }
1507
1508 #[test]
1509 fn test_strips_event_handler() {
1510 let tag = "<svg onclick=\"alert(1)\" width=\"100\">";
1511 let result = sanitize_tag_attrs(tag);
1512 assert!(!result.contains("onclick"));
1513 assert!(result.contains("width"));
1514 }
1515
1516 #[test]
1517 fn test_non_ascii_in_attr_value_preserved() {
1518 let tag = "<text title=\"日本語テスト\" x=\"10\">";
1519 let result = sanitize_tag_attrs(tag);
1520 assert!(result.contains("日本語テスト"));
1521 assert!(result.contains("x=\"10\""));
1522 }
1523
1524 #[test]
1527 fn test_strips_mixed_case_event_handler() {
1528 let tag = "<svg OnClick=\"alert(1)\" width=\"100\">";
1529 let result = sanitize_tag_attrs(tag);
1530 assert!(!result.contains("OnClick"));
1531 assert!(result.contains("width"));
1532 }
1533
1534 #[test]
1535 fn test_strips_uppercase_event_handler() {
1536 let tag = "<svg ONLOAD=\"alert(1)\">";
1537 let result = sanitize_tag_attrs(tag);
1538 assert!(!result.contains("ONLOAD"));
1539 }
1540
1541 #[test]
1544 fn test_strips_style_with_url() {
1545 let tag =
1546 "<rect style=\"background-image: url('https://attacker.com/exfil')\" width=\"10\">";
1547 let result = sanitize_tag_attrs(tag);
1548 assert!(!result.contains("style"));
1549 assert!(result.contains("width"));
1550 }
1551
1552 #[test]
1553 fn test_strips_style_with_expression() {
1554 let tag = "<rect style=\"width: expression(alert(1))\">";
1555 let result = sanitize_tag_attrs(tag);
1556 assert!(!result.contains("style"));
1557 }
1558
1559 #[test]
1560 fn test_strips_style_with_import() {
1561 let tag = "<rect style=\"@import url(evil.css)\">";
1562 let result = sanitize_tag_attrs(tag);
1563 assert!(!result.contains("style"));
1564 }
1565
1566 #[test]
1567 fn test_preserves_safe_style() {
1568 let tag = "<rect style=\"fill: red; stroke: blue\" width=\"10\">";
1569 let result = sanitize_tag_attrs(tag);
1570 assert!(result.contains("style"));
1571 assert!(result.contains("fill: red"));
1572 }
1573}
1574
1575#[cfg(test)]
1576mod tests {
1577 use super::*;
1578
1579 #[test]
1580 fn test_render_empty() {
1581 let song = chordsketch_core::parse("").unwrap();
1582 let html = render_song(&song);
1583 assert!(html.contains("<!DOCTYPE html>"));
1584 assert!(html.contains("</html>"));
1585 }
1586
1587 #[test]
1588 fn test_render_title() {
1589 let html = render("{title: My Song}");
1590 assert!(html.contains("<h1>My Song</h1>"));
1591 assert!(html.contains("<title>My Song</title>"));
1592 }
1593
1594 #[test]
1595 fn test_render_subtitle() {
1596 let html = render("{title: Song}\n{subtitle: By Someone}");
1597 assert!(html.contains("<h2>By Someone</h2>"));
1598 }
1599
1600 #[test]
1601 fn test_render_lyrics_with_chords() {
1602 let html = render("[Am]Hello [G]world");
1603 assert!(html.contains("chord-block"));
1604 assert!(html.contains("<span class=\"chord\">Am</span>"));
1605 assert!(html.contains("<span class=\"lyrics\">Hello </span>"));
1606 assert!(html.contains("<span class=\"chord\">G</span>"));
1607 }
1608
1609 #[test]
1610 fn test_render_lyrics_no_chords() {
1611 let html = render("Just plain text");
1612 assert!(html.contains("<span class=\"lyrics\">Just plain text</span>"));
1613 assert!(!html.contains("class=\"chord\""));
1615 }
1616
1617 #[test]
1618 fn test_render_chorus_section() {
1619 let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}");
1620 assert!(html.contains("<section class=\"chorus\">"));
1621 assert!(html.contains("</section>"));
1622 assert!(html.contains("Chorus"));
1623 }
1624
1625 #[test]
1626 fn test_render_verse_with_label() {
1627 let html = render("{start_of_verse: Verse 1}\nLyrics\n{end_of_verse}");
1628 assert!(html.contains("<section class=\"verse\">"));
1629 assert!(html.contains("Verse: Verse 1"));
1630 }
1631
1632 #[test]
1633 fn test_render_comment() {
1634 let html = render("{comment: A note}");
1635 assert!(html.contains("<p class=\"comment\">A note</p>"));
1636 }
1637
1638 #[test]
1639 fn test_render_comment_italic() {
1640 let html = render("{comment_italic: Softly}");
1641 assert!(html.contains("<em>Softly</em>"));
1642 }
1643
1644 #[test]
1645 fn test_render_comment_box() {
1646 let html = render("{comment_box: Important}");
1647 assert!(html.contains("<div class=\"comment-box\">Important</div>"));
1648 }
1649
1650 #[test]
1651 fn test_html_escaping() {
1652 let html = render("{title: Tom & Jerry <3}");
1653 assert!(html.contains("Tom & Jerry <3"));
1654 }
1655
1656 #[test]
1657 fn test_try_render_success() {
1658 let result = try_render("{title: Test}");
1659 assert!(result.is_ok());
1660 }
1661
1662 #[test]
1663 fn test_try_render_error() {
1664 let result = try_render("{unclosed");
1665 assert!(result.is_err());
1666 }
1667
1668 #[test]
1669 fn test_render_valid_html_structure() {
1670 let html = render("{title: Test}\n\n{start_of_verse}\n[G]Hello [C]world\n{end_of_verse}");
1671 assert!(html.starts_with("<!DOCTYPE html>"));
1672 assert!(html.contains("<html"));
1673 assert!(html.contains("<head>"));
1674 assert!(html.contains("<style>"));
1675 assert!(html.contains("<body>"));
1676 assert!(html.contains("</html>"));
1677 }
1678
1679 #[test]
1680 fn test_text_before_first_chord() {
1681 let html = render("Hello [Am]world");
1682 assert!(html.contains("<span class=\"chord\"></span><span class=\"lyrics\">Hello </span>"));
1684 }
1685
1686 #[test]
1687 fn test_empty_line() {
1688 let html = render("Line one\n\nLine two");
1689 assert!(html.contains("empty-line"));
1690 }
1691
1692 #[test]
1693 fn test_render_grid_section() {
1694 let html = render("{start_of_grid}\n| Am . | C . |\n{end_of_grid}");
1695 assert!(html.contains("<section class=\"grid\">"));
1696 assert!(html.contains("Grid"));
1697 assert!(html.contains("</section>"));
1698 }
1699
1700 #[test]
1703 fn test_render_custom_section_intro() {
1704 let html = render("{start_of_intro}\n[Am]Da da\n{end_of_intro}");
1705 assert!(html.contains("<section class=\"section-intro\">"));
1706 assert!(html.contains("Intro"));
1707 assert!(html.contains("</section>"));
1708 }
1709
1710 #[test]
1711 fn test_render_grid_section_with_label() {
1712 let html = render("{start_of_grid: Intro}\n| Am |\n{end_of_grid}");
1713 assert!(html.contains("<section class=\"grid\">"));
1714 assert!(html.contains("Grid: Intro"));
1715 }
1716
1717 #[test]
1718 fn test_render_grid_short_alias() {
1719 let html = render("{sog}\n| G . |\n{eog}");
1720 assert!(html.contains("<section class=\"grid\">"));
1721 assert!(html.contains("</section>"));
1722 }
1723
1724 #[test]
1725 fn test_render_custom_section_with_label() {
1726 let html = render("{start_of_intro: Guitar}\nNotes\n{end_of_intro}");
1727 assert!(html.contains("<section class=\"section-intro\">"));
1728 assert!(html.contains("Intro: Guitar"));
1729 }
1730
1731 #[test]
1732 fn test_render_custom_section_outro() {
1733 let html = render("{start_of_outro}\nFinal\n{end_of_outro}");
1734 assert!(html.contains("<section class=\"section-outro\">"));
1735 assert!(html.contains("Outro"));
1736 }
1737
1738 #[test]
1739 fn test_render_custom_section_solo() {
1740 let html = render("{start_of_solo}\n[Em]Solo\n{end_of_solo}");
1741 assert!(html.contains("<section class=\"section-solo\">"));
1742 assert!(html.contains("Solo"));
1743 assert!(html.contains("</section>"));
1744 }
1745
1746 #[test]
1747 fn test_custom_section_name_escaped() {
1748 let html = render(
1749 "{start_of_x<script>alert(1)</script>}\ntext\n{end_of_x<script>alert(1)</script>}",
1750 );
1751 assert!(!html.contains("<script>"));
1752 assert!(html.contains("<script>"));
1753 }
1754
1755 #[test]
1756 fn test_custom_section_name_quotes_escaped() {
1757 let html =
1758 render("{start_of_x\" onclick=\"alert(1)}\ntext\n{end_of_x\" onclick=\"alert(1)}");
1759 assert!(html.contains("""));
1761 assert!(!html.contains("class=\"section-x\""));
1762 }
1763
1764 #[test]
1765 fn test_custom_section_name_single_quotes_escaped() {
1766 let html = render("{start_of_x' onclick='alert(1)}\ntext\n{end_of_x' onclick='alert(1)}");
1767 assert!(html.contains("'") || html.contains("'"));
1770 assert!(!html.contains("onclick='alert"));
1771 }
1772
1773 #[test]
1774 fn test_custom_section_name_space_sanitized_in_class() {
1775 let html = render("{start_of_foo bar}\ntext\n{end_of_foo bar}");
1777 assert!(html.contains("section-foo-bar"));
1779 assert!(!html.contains("class=\"section-foo bar\""));
1780 }
1781
1782 #[test]
1783 fn test_custom_section_name_special_chars_sanitized_in_class() {
1784 let html = render("{start_of_a&b<c>d}\ntext\n{end_of_a&b<c>d}");
1785 assert!(html.contains("section-a-b-c-d"));
1787 assert!(html.contains("&"));
1789 }
1790
1791 #[test]
1792 fn test_custom_section_capitalize_before_escape() {
1793 let html = render("{start_of_&test}\ntext\n{end_of_&test}");
1797 assert!(html.contains("&test"));
1800 assert!(!html.contains("&Amp;"));
1801 }
1802
1803 #[test]
1804 fn test_define_display_name_in_html_output() {
1805 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0 display=\"A minor\"}");
1806 assert!(
1807 html.contains("A minor"),
1808 "display name should appear in rendered HTML output"
1809 );
1810 }
1811}
1812
1813#[cfg(test)]
1814mod transpose_tests {
1815 use super::*;
1816
1817 #[test]
1818 fn test_transpose_directive_up_2() {
1819 let input = "{transpose: 2}\n[G]Hello [C]world";
1820 let song = chordsketch_core::parse(input).unwrap();
1821 let html = render_song(&song);
1822 assert!(html.contains("<span class=\"chord\">A</span>"));
1824 assert!(html.contains("<span class=\"chord\">D</span>"));
1825 assert!(!html.contains("<span class=\"chord\">G</span>"));
1826 assert!(!html.contains("<span class=\"chord\">C</span>"));
1827 }
1828
1829 #[test]
1830 fn test_transpose_directive_replaces_previous() {
1831 let input = "{transpose: 2}\n[G]First\n{transpose: 0}\n[G]Second";
1832 let song = chordsketch_core::parse(input).unwrap();
1833 let html = render_song(&song);
1834 assert!(html.contains("<span class=\"chord\">A</span>"));
1836 assert!(html.contains("<span class=\"chord\">G</span>"));
1837 }
1838
1839 #[test]
1840 fn test_transpose_directive_with_cli_offset() {
1841 let input = "{transpose: 2}\n[C]Hello";
1842 let song = chordsketch_core::parse(input).unwrap();
1843 let html = render_song_with_transpose(&song, 3, &Config::defaults());
1844 assert!(html.contains("<span class=\"chord\">F</span>"));
1846 }
1847
1848 #[test]
1851 fn test_render_chorus_recall_basic() {
1852 let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}\n\n{chorus}");
1853 assert!(html.contains("<div class=\"chorus-recall\">"));
1855 assert!(html.contains("chorus-recall"));
1857 assert!(html.contains("<section class=\"chorus\">"));
1859 }
1860
1861 #[test]
1862 fn test_render_chorus_recall_with_label() {
1863 let html = render("{start_of_chorus}\nSing\n{end_of_chorus}\n{chorus: Repeat}");
1864 assert!(html.contains("Chorus: Repeat"));
1865 assert!(html.contains("chorus-recall"));
1866 }
1867
1868 #[test]
1869 fn test_render_chorus_recall_no_chorus_defined() {
1870 let html = render("{chorus}");
1871 assert!(html.contains("<div class=\"chorus-recall\">"));
1873 assert!(html.contains("Chorus"));
1874 }
1875
1876 #[test]
1877 fn test_render_chorus_recall_content_replayed() {
1878 let html = render("{start_of_chorus}\nChorus text\n{end_of_chorus}\n{chorus}");
1879 let count = html.matches("Chorus text").count();
1881 assert_eq!(count, 2, "chorus content should appear twice");
1882 }
1883
1884 #[test]
1885 fn test_chorus_recall_applies_current_transpose() {
1886 let html = render("{start_of_chorus}\n[G]La la\n{end_of_chorus}\n{transpose: 2}\n{chorus}");
1889 assert!(
1891 html.contains("<span class=\"chord\">G</span>"),
1892 "original chorus should have G"
1893 );
1894 assert!(
1896 html.contains("<span class=\"chord\">A</span>"),
1897 "recalled chorus should have transposed chord A, got:\n{html}"
1898 );
1899 }
1900
1901 #[test]
1902 fn test_chorus_recall_preserves_formatting_directives() {
1903 let html =
1905 render("{start_of_chorus}\n{textsize: 20}\n[Am]Big text\n{end_of_chorus}\n{chorus}");
1906 let recall_start = html.find("chorus-recall").expect("should have recall");
1908 let recall_section = &html[recall_start..];
1909 assert!(
1910 recall_section.contains("font-size"),
1911 "recalled chorus should apply in-chorus formatting directives"
1912 );
1913 }
1914
1915 #[test]
1916 fn test_chorus_formatting_does_not_leak_to_outer_scope() {
1917 let html =
1919 render("{start_of_chorus}\n{textsize: 20}\n[Am]Big\n{end_of_chorus}\n[G]Normal text");
1920 let after_chorus = html
1922 .rfind("Normal text")
1923 .expect("should have post-chorus text");
1924 let line_start = html[..after_chorus].rfind("<div class=\"line\"").unwrap();
1926 let line_end = html[line_start..]
1927 .find("</div>")
1928 .map_or(html.len(), |i| line_start + i + 6);
1929 let post_chorus_line = &html[line_start..line_end];
1930 assert!(
1931 !post_chorus_line.contains("font-size"),
1932 "in-chorus {{textsize}} should not leak to post-chorus content: {post_chorus_line}"
1933 );
1934 }
1935
1936 #[test]
1939 fn test_render_bold_markup() {
1940 let html = render("Hello <b>bold</b> world");
1941 assert!(html.contains("<b>bold</b>"));
1942 assert!(html.contains("Hello "));
1943 assert!(html.contains(" world"));
1944 }
1945
1946 #[test]
1947 fn test_render_italic_markup() {
1948 let html = render("Hello <i>italic</i> text");
1949 assert!(html.contains("<i>italic</i>"));
1950 }
1951
1952 #[test]
1953 fn test_render_highlight_markup() {
1954 let html = render("<highlight>important</highlight>");
1955 assert!(html.contains("<mark>important</mark>"));
1956 }
1957
1958 #[test]
1959 fn test_render_comment_inline_markup() {
1960 let html = render("<comment>note</comment>");
1961 assert!(html.contains("<span class=\"comment\">note</span>"));
1962 }
1963
1964 #[test]
1965 fn test_render_span_with_foreground() {
1966 let html = render(r#"<span foreground="red">red text</span>"#);
1967 assert!(html.contains("color: red;"));
1968 assert!(html.contains("red text"));
1969 }
1970
1971 #[test]
1972 fn test_render_span_with_multiple_attrs() {
1973 let html = render(
1974 r#"<span font_family="Serif" size="14" foreground="blue" weight="bold">styled</span>"#,
1975 );
1976 assert!(html.contains("font-family: Serif;"));
1977 assert!(html.contains("font-size: 14pt;"));
1978 assert!(html.contains("color: blue;"));
1979 assert!(html.contains("font-weight: bold;"));
1980 assert!(html.contains("styled"));
1981 }
1982
1983 #[test]
1984 fn test_span_css_injection_url_prevented() {
1985 let html = render(
1986 r#"<span foreground="red; background-image: url('https://evil.com/')">text</span>"#,
1987 );
1988 assert!(!html.contains("url("));
1990 assert!(!html.contains(";background-image"));
1991 }
1992
1993 #[test]
1994 fn test_span_css_injection_semicolon_stripped() {
1995 let html =
1996 render(r#"<span foreground="red; position: absolute; z-index: 9999">text</span>"#);
1997 assert!(!html.contains(";position"));
2001 assert!(!html.contains("; position"));
2002 assert!(html.contains("color:"));
2003 }
2004
2005 #[test]
2006 fn test_render_nested_markup() {
2007 let html = render("<b><i>bold italic</i></b>");
2008 assert!(html.contains("<b><i>bold italic</i></b>"));
2009 }
2010
2011 #[test]
2012 fn test_render_markup_with_chord() {
2013 let html = render("[Am]Hello <b>bold</b> world");
2014 assert!(html.contains("<b>bold</b>"));
2015 assert!(html.contains("<span class=\"chord\">Am</span>"));
2016 }
2017
2018 #[test]
2019 fn test_render_no_markup_unchanged() {
2020 let html = render("Just plain text");
2021 assert!(!html.contains("<b>"));
2023 assert!(!html.contains("<i>"));
2024 assert!(html.contains("Just plain text"));
2025 }
2026
2027 #[test]
2030 fn test_textfont_directive_applies_css() {
2031 let html = render("{textfont: Courier}\nHello world");
2032 assert!(html.contains("font-family: Courier;"));
2033 }
2034
2035 #[test]
2036 fn test_textsize_directive_applies_css() {
2037 let html = render("{textsize: 14}\nHello world");
2038 assert!(html.contains("font-size: 14pt;"));
2039 }
2040
2041 #[test]
2042 fn test_textcolour_directive_applies_css() {
2043 let html = render("{textcolour: blue}\nHello world");
2044 assert!(html.contains("color: blue;"));
2045 }
2046
2047 #[test]
2048 fn test_chordfont_directive_applies_css() {
2049 let html = render("{chordfont: Monospace}\n[Am]Hello");
2050 assert!(html.contains("font-family: Monospace;"));
2051 }
2052
2053 #[test]
2054 fn test_chordsize_directive_applies_css() {
2055 let html = render("{chordsize: 16}\n[Am]Hello");
2056 assert!(html.contains("font-size: 16pt;"));
2058 }
2059
2060 #[test]
2061 fn test_chordcolour_directive_applies_css() {
2062 let html = render("{chordcolour: green}\n[Am]Hello");
2063 assert!(html.contains("color: green;"));
2064 }
2065
2066 #[test]
2067 fn test_formatting_persists_across_lines() {
2068 let html = render("{textcolour: red}\nLine one\nLine two");
2069 let count = html.matches("color: red;").count();
2071 assert!(
2072 count >= 2,
2073 "formatting should persist: found {count} matches"
2074 );
2075 }
2076
2077 #[test]
2078 fn test_formatting_overridden_by_later_directive() {
2079 let html = render("{textcolour: red}\nRed text\n{textcolour: blue}\nBlue text");
2080 assert!(html.contains("color: red;"));
2081 assert!(html.contains("color: blue;"));
2082 }
2083
2084 #[test]
2085 fn test_no_formatting_no_style_attr() {
2086 let html = render("Plain text");
2087 assert!(!html.contains("<span class=\"lyrics\" style="));
2089 }
2090
2091 #[test]
2092 fn test_formatting_directive_css_injection_prevented() {
2093 let html = render("{textcolour: red; position: fixed; z-index: 9999}\nHello");
2094 assert!(!html.contains(";position"));
2096 assert!(!html.contains("; position"));
2097 assert!(html.contains("color:"));
2098 }
2099
2100 #[test]
2101 fn test_formatting_directive_url_injection_prevented() {
2102 let html = render("{textcolour: red; background-image: url('https://evil.com/')}\nHello");
2103 assert!(!html.contains("url("));
2105 }
2106
2107 #[test]
2110 fn test_columns_directive_generates_css() {
2111 let html = render("{columns: 2}\nLine one\nLine two");
2112 assert!(html.contains("column-count: 2"));
2113 }
2114
2115 #[test]
2116 fn test_columns_reset_to_one() {
2117 let html = render("{columns: 2}\nTwo cols\n{columns: 1}\nOne col");
2118 let count = html.matches("column-count: 2").count();
2120 assert_eq!(count, 1);
2121 assert!(html.contains("One col"));
2122 }
2123
2124 #[test]
2125 fn test_column_break_generates_css() {
2126 let html = render("{columns: 2}\nCol 1\n{column_break}\nCol 2");
2127 assert!(html.contains("break-before: column;"));
2128 }
2129
2130 #[test]
2131 fn test_columns_clamped_to_max() {
2132 let html = render("{columns: 999}\nContent");
2133 assert!(html.contains("column-count: 32"));
2135 }
2136
2137 #[test]
2138 fn test_columns_zero_treated_as_one() {
2139 let html = render("{columns: 0}\nContent");
2140 assert!(!html.contains("column-count"));
2142 }
2143
2144 #[test]
2145 fn test_columns_non_numeric_defaults_to_one() {
2146 let html = render("{columns: abc}\nHello");
2147 assert!(!html.contains("column-count"));
2149 }
2150
2151 #[test]
2152 fn test_new_page_generates_page_break() {
2153 let html = render("Page 1\n{new_page}\nPage 2");
2154 assert!(html.contains("break-before: page;"));
2155 }
2156
2157 #[test]
2158 fn test_new_physical_page_generates_recto_break() {
2159 let html = render("Page 1\n{new_physical_page}\nPage 2");
2160 assert!(
2161 html.contains("break-before: recto;"),
2162 "new_physical_page should use break-before: recto for duplex printing"
2163 );
2164 assert!(
2165 !html.contains("break-before: page;"),
2166 "new_physical_page should not emit generic page break"
2167 );
2168 }
2169
2170 #[test]
2171 fn test_page_control_not_replayed_in_chorus_recall() {
2172 let input = "\
2174{start_of_chorus}\n\
2175{new_page}\n\
2176[G]La la la\n\
2177{end_of_chorus}\n\
2178Verse text\n\
2179{chorus}";
2180 let html = render(input);
2181 assert!(html.contains("break-before: page;"));
2183 let count = html.matches("break-before: page;").count();
2186 assert_eq!(count, 1, "page break must not be replayed in chorus recall");
2187 }
2188
2189 #[test]
2192 fn test_image_basic() {
2193 let html = render("{image: src=photo.jpg}");
2194 assert!(html.contains("<img src=\"photo.jpg\""));
2195 }
2196
2197 #[test]
2198 fn test_image_with_dimensions() {
2199 let html = render("{image: src=photo.jpg width=200 height=100}");
2200 assert!(html.contains("width=\"200\""));
2201 assert!(html.contains("height=\"100\""));
2202 }
2203
2204 #[test]
2205 fn test_image_with_title() {
2206 let html = render("{image: src=photo.jpg title=\"My Photo\"}");
2207 assert!(html.contains("alt=\"My Photo\""));
2208 }
2209
2210 #[test]
2211 fn test_image_with_scale() {
2212 let html = render("{image: src=photo.jpg scale=0.5}");
2213 assert!(html.contains("scale(0.5)"));
2214 }
2215
2216 #[test]
2217 fn test_image_empty_src_skipped() {
2218 let html = render("{image: src=}");
2219 assert!(
2220 !html.contains("<img"),
2221 "empty src should not produce an img element"
2222 );
2223 }
2224
2225 #[test]
2226 fn test_image_javascript_uri_rejected() {
2227 let html = render("{image: src=javascript:alert(1)}");
2228 assert!(!html.contains("<img"), "javascript: URI must be rejected");
2229 }
2230
2231 #[test]
2232 fn test_image_data_uri_rejected() {
2233 let html = render("{image: src=data:text/html,<script>alert(1)</script>}");
2234 assert!(!html.contains("<img"), "data: URI must be rejected");
2235 }
2236
2237 #[test]
2238 fn test_image_vbscript_uri_rejected() {
2239 let html = render("{image: src=vbscript:MsgBox}");
2240 assert!(!html.contains("<img"), "vbscript: URI must be rejected");
2241 }
2242
2243 #[test]
2244 fn test_image_javascript_uri_case_insensitive() {
2245 let html = render("{image: src=JaVaScRiPt:alert(1)}");
2246 assert!(
2247 !html.contains("<img"),
2248 "scheme check must be case-insensitive"
2249 );
2250 }
2251
2252 #[test]
2253 fn test_image_safe_relative_path_allowed() {
2254 let html = render("{image: src=images/photo.jpg}");
2255 assert!(html.contains("<img src=\"images/photo.jpg\""));
2256 }
2257
2258 #[test]
2259 fn test_is_safe_image_src() {
2260 assert!(is_safe_image_src("photo.jpg"));
2262 assert!(is_safe_image_src("images/photo.jpg"));
2263 assert!(is_safe_image_src("path/to:file.jpg")); assert!(is_safe_image_src("http://example.com/photo.jpg"));
2267 assert!(is_safe_image_src("https://example.com/photo.jpg"));
2268 assert!(is_safe_image_src("HTTP://EXAMPLE.COM/PHOTO.JPG"));
2269
2270 assert!(!is_safe_image_src(""));
2272
2273 assert!(!is_safe_image_src("javascript:alert(1)"));
2275 assert!(!is_safe_image_src("JAVASCRIPT:alert(1)"));
2276 assert!(!is_safe_image_src(" javascript:alert(1)"));
2277 assert!(!is_safe_image_src("data:image/png;base64,abc"));
2278 assert!(!is_safe_image_src("vbscript:MsgBox"));
2279
2280 assert!(!is_safe_image_src("file:///etc/passwd"));
2282 assert!(!is_safe_image_src("FILE:///etc/passwd"));
2283 assert!(!is_safe_image_src("blob:https://example.com/uuid"));
2284 assert!(!is_safe_image_src("mhtml:file://C:/page.mhtml"));
2285
2286 assert!(!is_safe_image_src("/etc/passwd"));
2288 assert!(!is_safe_image_src("/home/user/photo.jpg"));
2289
2290 assert!(!is_safe_image_src("photo\0.jpg"));
2292 assert!(!is_safe_image_src("\0"));
2293
2294 assert!(!is_safe_image_src("../photo.jpg"));
2296 assert!(!is_safe_image_src("images/../../etc/passwd"));
2297 assert!(!is_safe_image_src(r"..\photo.jpg"));
2298 assert!(!is_safe_image_src(r"images\..\..\photo.jpg"));
2299
2300 assert!(!is_safe_image_src(r"C:\photo.jpg"));
2302 assert!(!is_safe_image_src(r"D:\Users\photo.jpg"));
2303 assert!(!is_safe_image_src(r"\\server\share\photo.jpg"));
2304 assert!(!is_safe_image_src("C:/photo.jpg"));
2305 }
2306
2307 #[test]
2308 fn test_image_anchor_column_centers() {
2309 let html = render("{image: src=photo.jpg anchor=column}");
2310 assert!(
2311 html.contains("<div style=\"text-align: center;\">"),
2312 "anchor=column should produce centered div"
2313 );
2314 }
2315
2316 #[test]
2317 fn test_image_anchor_paper_centers() {
2318 let html = render("{image: src=photo.jpg anchor=paper}");
2319 assert!(
2320 html.contains("<div style=\"text-align: center;\">"),
2321 "anchor=paper should produce centered div"
2322 );
2323 }
2324
2325 #[test]
2326 fn test_image_anchor_line_no_style() {
2327 let html = render("{image: src=photo.jpg anchor=line}");
2328 assert!(html.contains("<div><img"));
2330 assert!(!html.contains("text-align"));
2331 }
2332
2333 #[test]
2334 fn test_image_no_anchor_no_style() {
2335 let html = render("{image: src=photo.jpg}");
2336 assert!(html.contains("<div><img"));
2338 assert!(!html.contains("text-align"));
2339 }
2340
2341 #[test]
2342 fn test_image_max_width_css_present() {
2343 let html = render("{image: src=photo.jpg}");
2344 assert!(
2345 html.contains("img { max-width: 100%; height: auto; }"),
2346 "CSS should include img max-width rule to prevent overflow"
2347 );
2348 }
2349
2350 #[test]
2351 fn test_chord_diagram_css_rules_present() {
2352 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
2353 assert!(
2354 html.contains(".chord-diagram-container"),
2355 "CSS should include .chord-diagram-container rule"
2356 );
2357 assert!(
2358 html.contains(".chord-diagram {"),
2359 "CSS should include .chord-diagram rule"
2360 );
2361 }
2362
2363 #[test]
2366 fn test_define_renders_svg_diagram() {
2367 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
2368 assert!(html.contains("<svg"));
2369 assert!(html.contains("Am"));
2370 assert!(html.contains("chord-diagram"));
2371 }
2372
2373 #[test]
2374 fn test_define_keyboard_no_diagram() {
2375 let html = render("{define: Am keys 0 3 7}");
2376 assert!(!html.contains("<svg"));
2378 }
2379
2380 #[test]
2381 fn test_define_ukulele_diagram() {
2382 let html = render("{define: C frets 0 0 0 3}");
2383 assert!(html.contains("<svg"));
2384 assert!(html.contains("chord-diagram"));
2385 assert!(
2387 html.contains("width=\"88\""),
2388 "Expected 4-string SVG width (88)"
2389 );
2390 }
2391
2392 #[test]
2393 fn test_define_banjo_diagram() {
2394 let html = render("{define: G frets 0 0 0 0 0}");
2395 assert!(html.contains("<svg"));
2396 assert!(
2398 html.contains("width=\"104\""),
2399 "Expected 5-string SVG width (104)"
2400 );
2401 }
2402
2403 #[test]
2404 fn test_diagrams_frets_config_controls_svg_height() {
2405 let input = "{define: Am base-fret 1 frets x 0 2 2 1 0}";
2406 let song = chordsketch_core::parse(input).unwrap();
2407 let config = chordsketch_core::config::Config::defaults()
2408 .with_define("diagrams.frets=4")
2409 .unwrap();
2410 let html = render_song_with_transpose(&song, 0, &config);
2411 assert!(
2413 html.contains("height=\"140\""),
2414 "SVG height should reflect diagrams.frets=4 (expected 140)"
2415 );
2416 }
2417
2418 #[test]
2421 fn test_diagrams_off_suppresses_chord_diagrams() {
2422 let html = render("{diagrams: off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
2423 assert!(
2424 !html.contains("<svg"),
2425 "chord diagram SVG should be suppressed when diagrams=off"
2426 );
2427 }
2428
2429 #[test]
2430 fn test_diagrams_on_shows_chord_diagrams() {
2431 let html = render("{diagrams: on}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
2432 assert!(
2433 html.contains("<svg"),
2434 "chord diagram SVG should be shown when diagrams=on"
2435 );
2436 }
2437
2438 #[test]
2439 fn test_diagrams_default_shows_chord_diagrams() {
2440 let html = render("{define: Am base-fret 1 frets x 0 2 2 1 0}");
2441 assert!(
2442 html.contains("<svg"),
2443 "chord diagram SVG should be shown by default"
2444 );
2445 }
2446
2447 #[test]
2448 fn test_diagrams_off_then_on_restores() {
2449 let html = render(
2450 "{diagrams: off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}\n{diagrams: on}\n{define: G base-fret 1 frets 3 2 0 0 0 3}",
2451 );
2452 assert!(!html.contains(">Am<"), "Am diagram should be suppressed");
2454 assert!(html.contains(">G<"), "G diagram should be rendered");
2455 }
2456
2457 #[test]
2458 fn test_diagrams_parsed_as_known_directive() {
2459 let song = chordsketch_core::parse("{diagrams: off}").unwrap();
2460 if let chordsketch_core::ast::Line::Directive(d) = &song.lines[0] {
2461 assert_eq!(
2462 d.kind,
2463 chordsketch_core::ast::DirectiveKind::Diagrams,
2464 "diagrams should parse as DirectiveKind::Diagrams"
2465 );
2466 assert_eq!(d.value, Some("off".to_string()));
2467 } else {
2468 panic!("expected a directive line, got: {:?}", &song.lines[0]);
2469 }
2470 }
2471
2472 #[test]
2475 fn test_diagrams_off_case_insensitive() {
2476 let html = render("{diagrams: Off}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
2477 assert!(
2478 !html.contains("<svg"),
2479 "diagrams=Off should suppress diagrams (case-insensitive)"
2480 );
2481 }
2482
2483 #[test]
2484 fn test_diagrams_off_uppercase() {
2485 let html = render("{diagrams: OFF}\n{define: Am base-fret 1 frets x 0 2 2 1 0}");
2486 assert!(
2487 !html.contains("<svg"),
2488 "diagrams=OFF should suppress diagrams (case-insensitive)"
2489 );
2490 }
2491
2492 #[test]
2495 fn test_abc_section_disabled_by_config() {
2496 let input = "{start_of_abc}\nX:1\n{end_of_abc}";
2498 let song = chordsketch_core::parse(input).unwrap();
2499 let config = chordsketch_core::config::Config::defaults()
2500 .with_define("delegates.abc2svg=false")
2501 .unwrap();
2502 let html = render_song_with_transpose(&song, 0, &config);
2503 assert!(html.contains("<section class=\"abc\">"));
2504 assert!(html.contains("ABC"));
2505 assert!(html.contains("</section>"));
2506 }
2507
2508 #[test]
2509 fn test_abc_section_null_config_auto_detect_disabled() {
2510 if chordsketch_core::external_tool::has_abc2svg() {
2513 return; }
2515 let input = "{start_of_abc}\nX:1\n{end_of_abc}";
2516 let song = chordsketch_core::parse(input).unwrap();
2517 let config = chordsketch_core::config::Config::defaults();
2519 assert!(
2520 config.get_path("delegates.abc2svg").is_null(),
2521 "default config should have null delegates.abc2svg"
2522 );
2523 let html = render_song_with_transpose(&song, 0, &config);
2524 assert!(
2525 html.contains("<section class=\"abc\">"),
2526 "null auto-detect with no abc2svg should render as text section"
2527 );
2528 }
2529
2530 #[test]
2531 fn test_abc_section_fallback_preformatted() {
2532 if chordsketch_core::external_tool::has_abc2svg() {
2534 return; }
2536 let input = "{start_of_abc}\nX:1\nT:Test\nK:C\n{end_of_abc}";
2537 let song = chordsketch_core::parse(input).unwrap();
2538 let config = chordsketch_core::config::Config::defaults()
2539 .with_define("delegates.abc2svg=true")
2540 .unwrap();
2541 let html = render_song_with_transpose(&song, 0, &config);
2542 assert!(html.contains("<section class=\"abc\">"));
2543 assert!(html.contains("<pre>"));
2544 assert!(html.contains("X:1"));
2545 assert!(html.contains("</pre>"));
2546 }
2547
2548 #[test]
2549 fn test_abc_section_with_label_delegate_fallback() {
2550 if chordsketch_core::external_tool::has_abc2svg() {
2551 return;
2552 }
2553 let input = "{start_of_abc: Melody}\nX:1\n{end_of_abc}";
2554 let song = chordsketch_core::parse(input).unwrap();
2555 let config = chordsketch_core::config::Config::defaults()
2556 .with_define("delegates.abc2svg=true")
2557 .unwrap();
2558 let html = render_song_with_transpose(&song, 0, &config);
2559 assert!(html.contains("ABC: Melody"));
2560 assert!(html.contains("<pre>"));
2561 }
2562
2563 #[test]
2564 #[ignore]
2565 fn test_abc_section_renders_svg_with_abc2svg() {
2566 let input = "{start_of_abc}\nX:1\nT:Test\nM:4/4\nK:C\nCDEF|GABc|\n{end_of_abc}";
2568 let song = chordsketch_core::parse(input).unwrap();
2569 let config = chordsketch_core::config::Config::defaults()
2570 .with_define("delegates.abc2svg=true")
2571 .unwrap();
2572 let html = render_song_with_transpose(&song, 0, &config);
2573 assert!(html.contains("<section class=\"abc\">"));
2574 assert!(
2575 html.contains("<svg"),
2576 "should contain rendered SVG from abc2svg"
2577 );
2578 assert!(html.contains("</section>"));
2579 }
2580
2581 #[test]
2582 fn test_abc_section_auto_detect_default_config() {
2583 let input = "{start_of_abc}\nX:1\nT:Test\nK:C\n{end_of_abc}";
2587 let song = chordsketch_core::parse(input).unwrap();
2588 let config = chordsketch_core::config::Config::defaults();
2589 let html = render_song_with_transpose(&song, 0, &config);
2590 assert!(
2591 html.contains("<section class=\"abc\">"),
2592 "auto-detect should produce abc section"
2593 );
2594 if !chordsketch_core::external_tool::has_abc2svg() {
2595 assert!(
2596 html.contains("X:1"),
2597 "raw ABC content should be present without tool"
2598 );
2599 assert!(
2600 !html.contains("<svg"),
2601 "no SVG should be generated without abc2svg"
2602 );
2603 }
2604 }
2605
2606 #[test]
2609 fn test_ly_section_auto_detect_default_config() {
2610 let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
2612 let song = chordsketch_core::parse(input).unwrap();
2613 let config = chordsketch_core::config::Config::defaults();
2614 let html = render_song_with_transpose(&song, 0, &config);
2615 assert!(
2616 html.contains("<section class=\"ly\">"),
2617 "auto-detect should produce ly section"
2618 );
2619 if !chordsketch_core::external_tool::has_lilypond() {
2620 assert!(
2621 html.contains("\\relative"),
2622 "raw Lilypond content should be present without tool"
2623 );
2624 assert!(
2625 !html.contains("<svg"),
2626 "no SVG should be generated without lilypond"
2627 );
2628 }
2629 }
2630
2631 #[test]
2632 fn test_ly_section_disabled_by_config() {
2633 let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
2635 let song = chordsketch_core::parse(input).unwrap();
2636 let config = chordsketch_core::config::Config::defaults()
2637 .with_define("delegates.lilypond=false")
2638 .unwrap();
2639 let html = render_song_with_transpose(&song, 0, &config);
2640 assert!(html.contains("<section class=\"ly\">"));
2641 assert!(html.contains("Lilypond"));
2642 assert!(html.contains("</section>"));
2643 }
2644
2645 #[test]
2646 fn test_ly_section_fallback_preformatted() {
2647 if chordsketch_core::external_tool::has_lilypond() {
2648 return;
2649 }
2650 let input = "{start_of_ly}\n\\relative c' { c4 }\n{end_of_ly}";
2651 let song = chordsketch_core::parse(input).unwrap();
2652 let config = chordsketch_core::config::Config::defaults()
2653 .with_define("delegates.lilypond=true")
2654 .unwrap();
2655 let html = render_song_with_transpose(&song, 0, &config);
2656 assert!(html.contains("<section class=\"ly\">"));
2657 assert!(html.contains("<pre>"));
2658 assert!(html.contains("</pre>"));
2659 }
2660
2661 #[test]
2662 #[ignore]
2663 fn test_ly_section_renders_svg_with_lilypond() {
2664 let input = "{start_of_ly}\n\\relative c' { c4 d e f | g2 g | }\n{end_of_ly}";
2666 let song = chordsketch_core::parse(input).unwrap();
2667 let config = chordsketch_core::config::Config::defaults()
2668 .with_define("delegates.lilypond=true")
2669 .unwrap();
2670 let html = render_song_with_transpose(&song, 0, &config);
2671 assert!(html.contains("<section class=\"ly\">"));
2672 assert!(
2673 html.contains("<svg"),
2674 "should contain rendered SVG from lilypond"
2675 );
2676 assert!(html.contains("</section>"));
2677 }
2678}
2679
2680#[cfg(test)]
2681mod delegate_tests {
2682 use super::*;
2683
2684 #[test]
2685 fn test_render_abc_section() {
2686 let html = render("{start_of_abc}\nX:1\n{end_of_abc}");
2687 assert!(html.contains("<section class=\"abc\">"));
2688 assert!(html.contains("ABC"));
2689 assert!(html.contains("</section>"));
2690 }
2691
2692 #[test]
2693 fn test_render_abc_section_with_label() {
2694 let html = render("{start_of_abc: Melody}\nX:1\n{end_of_abc}");
2695 assert!(html.contains("<section class=\"abc\">"));
2696 assert!(html.contains("ABC: Melody"));
2697 }
2698
2699 #[test]
2700 fn test_render_ly_section() {
2701 let html = render("{start_of_ly}\nnotes\n{end_of_ly}");
2702 assert!(html.contains("<section class=\"ly\">"));
2703 assert!(html.contains("Lilypond"));
2704 assert!(html.contains("</section>"));
2705 }
2706
2707 #[test]
2708 fn test_abc_fallback_sanitizes_would_be_script_in_svg() {
2709 let malicious_svg = "<svg><script>alert(1)</script><circle r=\"5\"/></svg>";
2713 let sanitized = sanitize_svg_content(malicious_svg);
2714 assert!(
2715 !sanitized.contains("<script>"),
2716 "script tags must be stripped from delegate SVG output"
2717 );
2718 assert!(sanitized.contains("<circle"));
2719 }
2720
2721 #[test]
2722 fn test_sanitize_svg_strips_event_handlers_from_delegate_output() {
2723 let svg_with_handler = "<svg><rect onmouseover=\"alert(1)\" width=\"10\"/></svg>";
2724 let sanitized = sanitize_svg_content(svg_with_handler);
2725 assert!(
2726 !sanitized.contains("onmouseover"),
2727 "event handlers must be stripped from delegate SVG output"
2728 );
2729 assert!(sanitized.contains("<rect"));
2730 }
2731
2732 #[test]
2733 fn test_sanitize_svg_strips_foreignobject_from_delegate_output() {
2734 let svg = "<svg><foreignObject><body xmlns=\"http://www.w3.org/1999/xhtml\"><script>alert(1)</script></body></foreignObject></svg>";
2735 let sanitized = sanitize_svg_content(svg);
2736 assert!(
2737 !sanitized.contains("<foreignObject"),
2738 "foreignObject must be stripped from delegate SVG output"
2739 );
2740 }
2741
2742 #[test]
2743 fn test_sanitize_svg_strips_math_element() {
2744 let svg = "<svg><math><mi>x</mi></math></svg>";
2745 let sanitized = sanitize_svg_content(svg);
2746 assert!(
2747 !sanitized.contains("<math"),
2748 "math element must be stripped from delegate SVG output"
2749 );
2750 }
2751
2752 #[test]
2753 fn test_render_svg_section() {
2754 let html = render("{start_of_svg}\n<svg/>\n{end_of_svg}");
2755 assert!(html.contains("<div class=\"svg-section\">"));
2757 assert!(html.contains("<svg/>"));
2758 assert!(html.contains("</div>"));
2759 }
2760
2761 #[test]
2762 fn test_render_svg_inline_content() {
2763 let svg = r#"<svg width="100" height="100"><circle cx="50" cy="50" r="40"/></svg>"#;
2764 let input = format!("{{start_of_svg}}\n{svg}\n{{end_of_svg}}");
2765 let html = render(&input);
2766 assert!(html.contains(svg));
2767 }
2768
2769 #[test]
2770 fn test_svg_section_strips_script_tags() {
2771 let input = "{start_of_svg}\n<svg><script>alert('xss')</script><circle r=\"10\"/></svg>\n{end_of_svg}";
2772 let html = render(input);
2773 assert!(!html.contains("<script>"), "script tags must be stripped");
2774 assert!(!html.contains("alert"), "script content must be stripped");
2775 assert!(
2776 html.contains("<circle r=\"10\"/>"),
2777 "safe SVG content must be preserved"
2778 );
2779 }
2780
2781 #[test]
2782 fn test_svg_section_strips_event_handlers() {
2783 let input = "{start_of_svg}\n<svg onload=\"alert(1)\"><rect width=\"10\" onerror=\"hack()\"/></svg>\n{end_of_svg}";
2784 let html = render(input);
2785 assert!(!html.contains("onload"), "onload handler must be stripped");
2786 assert!(
2787 !html.contains("onerror"),
2788 "onerror handler must be stripped"
2789 );
2790 assert!(
2791 html.contains("width=\"10\""),
2792 "safe attributes must be preserved"
2793 );
2794 }
2795
2796 #[test]
2797 fn test_svg_section_preserves_safe_content() {
2798 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><text x="10" y="20">Hello</text></svg>"#;
2799 let input = format!("{{start_of_svg}}\n{svg}\n{{end_of_svg}}");
2800 let html = render(&input);
2801 assert!(html.contains("xmlns=\"http://www.w3.org/2000/svg\""));
2802 assert!(html.contains("<text x=\"10\" y=\"20\">Hello</text>"));
2803 }
2804
2805 #[test]
2806 fn test_svg_section_strips_case_insensitive_script() {
2807 let input = "{start_of_svg}\n<SCRIPT>alert(1)</SCRIPT><svg/>\n{end_of_svg}";
2808 let html = render(input);
2809 assert!(!html.contains("SCRIPT"), "case-insensitive script removal");
2810 assert!(!html.contains("alert"));
2811 assert!(html.contains("<svg/>"));
2812 }
2813
2814 #[test]
2815 fn test_svg_section_strips_foreignobject() {
2816 let input = "{start_of_svg}\n<svg><foreignObject><body onload=\"alert(1)\"></body></foreignObject><rect width=\"10\"/></svg>\n{end_of_svg}";
2817 let html = render(input);
2818 assert!(
2819 !html.contains("foreignObject"),
2820 "foreignObject must be stripped"
2821 );
2822 assert!(
2823 !html.contains("foreignobject"),
2824 "foreignObject (lowercase) must be stripped"
2825 );
2826 assert!(
2827 html.contains("<rect width=\"10\"/>"),
2828 "safe content must be preserved"
2829 );
2830 }
2831
2832 #[test]
2833 fn test_svg_section_strips_iframe() {
2834 let input = "{start_of_svg}\n<svg><iframe src=\"javascript:alert(1)\"></iframe><circle r=\"5\"/></svg>\n{end_of_svg}";
2835 let html = render(input);
2836 assert!(!html.contains("iframe"), "iframe must be stripped");
2837 assert!(html.contains("<circle r=\"5\"/>"));
2838 }
2839
2840 #[test]
2841 fn test_svg_section_strips_object_and_embed() {
2842 let input = "{start_of_svg}\n<svg><object data=\"evil.swf\"></object><embed src=\"evil.swf\"></embed><rect/></svg>\n{end_of_svg}";
2843 let html = render(input);
2844 assert!(!html.contains("object"), "object must be stripped");
2845 assert!(!html.contains("embed"), "embed must be stripped");
2846 assert!(html.contains("<rect/>"));
2847 }
2848
2849 #[test]
2850 fn test_svg_section_strips_javascript_uri_in_href() {
2851 let input = "{start_of_svg}\n<svg><a href=\"javascript:alert(1)\"><text>Click</text></a></svg>\n{end_of_svg}";
2852 let html = render(input);
2853 assert!(
2854 !html.contains("javascript:"),
2855 "javascript: URI must be stripped from href"
2856 );
2857 assert!(html.contains("<text>Click</text>"));
2858 }
2859
2860 #[test]
2861 fn test_svg_section_strips_vbscript_uri() {
2862 let input = "{start_of_svg}\n<svg><a href=\"vbscript:MsgBox\"><text>Click</text></a></svg>\n{end_of_svg}";
2863 let html = render(input);
2864 assert!(
2865 !html.contains("vbscript:"),
2866 "vbscript: URI must be stripped"
2867 );
2868 }
2869
2870 #[test]
2871 fn test_svg_section_strips_data_uri_in_use() {
2872 let input = "{start_of_svg}\n<svg><use href=\"data:image/svg+xml;base64,PHN2Zy8+\"/></svg>\n{end_of_svg}";
2873 let html = render(input);
2874 assert!(
2875 !html.contains("data:"),
2876 "data: URI must be stripped from use href"
2877 );
2878 }
2879
2880 #[test]
2881 fn test_svg_section_strips_javascript_uri_case_insensitive() {
2882 let input = "{start_of_svg}\n<svg><a href=\"JaVaScRiPt:alert(1)\"><text>X</text></a></svg>\n{end_of_svg}";
2883 let html = render(input);
2884 assert!(
2885 !html.to_lowercase().contains("javascript:"),
2886 "case-insensitive javascript: URI must be stripped"
2887 );
2888 }
2889
2890 #[test]
2891 fn test_svg_section_strips_xlink_href_dangerous_uri() {
2892 let input =
2893 "{start_of_svg}\n<svg><use xlink:href=\"javascript:alert(1)\"/></svg>\n{end_of_svg}";
2894 let html = render(input);
2895 assert!(
2896 !html.contains("javascript:"),
2897 "javascript: URI in xlink:href must be stripped"
2898 );
2899 }
2900
2901 #[test]
2902 fn test_svg_section_preserves_safe_href() {
2903 let input = "{start_of_svg}\n<svg><a href=\"https://example.com\"><text>Link</text></a></svg>\n{end_of_svg}";
2904 let html = render(input);
2905 assert!(
2906 html.contains("href=\"https://example.com\""),
2907 "safe https: href must be preserved"
2908 );
2909 }
2910
2911 #[test]
2912 fn test_svg_section_preserves_fragment_href() {
2913 let input = "{start_of_svg}\n<svg><use href=\"#myShape\"/></svg>\n{end_of_svg}";
2914 let html = render(input);
2915 assert!(
2916 html.contains("href=\"#myShape\""),
2917 "fragment-only href must be preserved"
2918 );
2919 }
2920
2921 #[test]
2922 fn test_render_textblock_section() {
2923 let html = render("{start_of_textblock}\nPreformatted\n{end_of_textblock}");
2924 assert!(html.contains("<section class=\"textblock\">"));
2925 assert!(html.contains("Textblock"));
2926 assert!(html.contains("</section>"));
2927 }
2928
2929 #[test]
2932 fn test_render_songs_single() {
2933 let songs = chordsketch_core::parse_multi("{title: Only}").unwrap();
2934 let html = render_songs(&songs);
2935 assert_eq!(html, render_song(&songs[0]));
2937 }
2938
2939 #[test]
2940 fn test_render_songs_two_songs_with_hr_separator() {
2941 let songs = chordsketch_core::parse_multi(
2942 "{title: Song A}\n[Am]Hello\n{new_song}\n{title: Song B}\n[G]World",
2943 )
2944 .unwrap();
2945 let html = render_songs(&songs);
2946 assert!(html.contains("<title>Song A</title>"));
2948 assert!(html.contains("<h1>Song A</h1>"));
2950 assert!(html.contains("<h1>Song B</h1>"));
2951 assert!(html.contains("<hr class=\"song-separator\">"));
2953 assert_eq!(html.matches("<div class=\"song\">").count(), 2);
2955 assert_eq!(html.matches("<!DOCTYPE html>").count(), 1);
2957 assert_eq!(html.matches("</html>").count(), 1);
2958 }
2959
2960 #[test]
2961 fn test_image_scale_css_injection_prevented() {
2962 let html = render("{image: src=photo.jpg scale=0.5); position: fixed; z-index: 9999}");
2965 assert!(!html.contains("position"));
2966 assert!(!html.contains("z-index"));
2967 assert!(!html.contains("position: fixed"));
2969 }
2970
2971 #[test]
2972 fn test_render_songs_with_transpose() {
2973 let songs =
2974 chordsketch_core::parse_multi("{title: S1}\n[C]Do\n{new_song}\n{title: S2}\n[G]Re")
2975 .unwrap();
2976 let html = render_songs_with_transpose(&songs, 2, &Config::defaults());
2977 assert!(html.contains(">D<"));
2979 assert!(html.contains(">A<"));
2980 }
2981
2982 #[test]
2985 fn test_sanitize_svg_strips_set_element() {
2986 let svg = r##"<svg><a href="#"><set attributeName="href" to="javascript:alert(1)"/><text>Click</text></a></svg>"##;
2987 let sanitized = sanitize_svg_content(svg);
2988 assert!(
2989 !sanitized.contains("<set"),
2990 "set element must be stripped to prevent SVG animation XSS"
2991 );
2992 assert!(sanitized.contains("<text>Click</text>"));
2993 }
2994
2995 #[test]
2996 fn test_sanitize_svg_strips_animate_element() {
2997 let svg =
2998 r#"<svg><animate attributeName="href" values="javascript:alert(1)"/><rect/></svg>"#;
2999 let sanitized = sanitize_svg_content(svg);
3000 assert!(
3001 !sanitized.contains("<animate"),
3002 "animate element must be stripped"
3003 );
3004 assert!(sanitized.contains("<rect/>"));
3005 }
3006
3007 #[test]
3008 fn test_sanitize_svg_strips_animatetransform() {
3009 let svg =
3010 "<svg><animateTransform attributeName=\"transform\" type=\"rotate\"/><rect/></svg>";
3011 let sanitized = sanitize_svg_content(svg);
3012 assert!(
3013 !sanitized.contains("animateTransform"),
3014 "animateTransform must be stripped"
3015 );
3016 assert!(
3017 !sanitized.contains("animatetransform"),
3018 "animatetransform (lowercase) must be stripped"
3019 );
3020 }
3021
3022 #[test]
3023 fn test_sanitize_svg_strips_animatemotion() {
3024 let svg = "<svg><animateMotion path=\"M0,0 L100,100\"/><rect/></svg>";
3025 let sanitized = sanitize_svg_content(svg);
3026 assert!(
3027 !sanitized.contains("animateMotion"),
3028 "animateMotion must be stripped"
3029 );
3030 }
3031
3032 #[test]
3033 fn test_sanitize_svg_strips_to_attr_with_dangerous_uri() {
3034 let svg = r#"<svg><a to="javascript:alert(1)"><text>X</text></a></svg>"#;
3035 let sanitized = sanitize_svg_content(svg);
3036 assert!(
3037 !sanitized.contains("javascript:"),
3038 "dangerous URI in 'to' attr must be stripped"
3039 );
3040 }
3041
3042 #[test]
3043 fn test_sanitize_svg_strips_values_attr_with_dangerous_uri() {
3044 let svg = r#"<svg><a values="javascript:alert(1)"><text>X</text></a></svg>"#;
3045 let sanitized = sanitize_svg_content(svg);
3046 assert!(
3047 !sanitized.contains("javascript:"),
3048 "dangerous URI in 'values' attr must be stripped"
3049 );
3050 }
3051
3052 #[test]
3055 fn test_strip_dangerous_attrs_preserves_cjk_text() {
3056 let input = "<svg><text x=\"10\">日本語テスト</text></svg>";
3057 let result = strip_dangerous_attrs(input);
3058 assert!(
3059 result.contains("日本語テスト"),
3060 "CJK characters must not be corrupted"
3061 );
3062 }
3063
3064 #[test]
3065 fn test_strip_dangerous_attrs_preserves_emoji() {
3066 let input = "<svg><text>🎵🎸🎹</text></svg>";
3067 let result = strip_dangerous_attrs(input);
3068 assert!(result.contains("🎵🎸🎹"), "emoji must not be corrupted");
3069 }
3070
3071 #[test]
3072 fn test_strip_dangerous_attrs_preserves_accented_chars() {
3073 let input = "<svg><text>café résumé naïve</text></svg>";
3074 let result = strip_dangerous_attrs(input);
3075 assert!(
3076 result.contains("café résumé naïve"),
3077 "accented characters must not be corrupted"
3078 );
3079 }
3080
3081 #[test]
3082 fn test_sanitize_svg_full_roundtrip_with_non_ascii() {
3083 let input = "<svg><text x=\"10\">コード譜 🎵</text><rect width=\"100\"/></svg>";
3084 let sanitized = sanitize_svg_content(input);
3085 assert!(sanitized.contains("コード譜 🎵"));
3086 assert!(sanitized.contains("<rect width=\"100\"/>"));
3087 }
3088
3089 #[test]
3090 fn test_sanitize_svg_self_closing_with_gt_in_attr_value() {
3091 let svg = r#"<svg><set to="a>b"/><text>safe</text></svg>"#;
3093 let sanitized = sanitize_svg_content(svg);
3094 assert!(
3095 !sanitized.contains("<set"),
3096 "dangerous <set> element must be stripped"
3097 );
3098 assert!(
3099 sanitized.contains("<text>safe</text>"),
3100 "content after stripped self-closing element must be preserved"
3101 );
3102 }
3103
3104 #[test]
3107 fn test_strip_dangerous_attrs_gt_in_double_quoted_attr() {
3108 let input = r#"<rect title=">" onload="alert(1)"/>"#;
3110 let result = strip_dangerous_attrs(input);
3111 assert!(
3112 !result.contains("onload"),
3113 "onload after quoted > must be stripped"
3114 );
3115 assert!(result.contains("title"));
3116 }
3117
3118 #[test]
3119 fn test_strip_dangerous_attrs_gt_in_single_quoted_attr() {
3120 let input = "<rect title='>' onload=\"alert(1)\"/>";
3121 let result = strip_dangerous_attrs(input);
3122 assert!(
3123 !result.contains("onload"),
3124 "onload after single-quoted > must be stripped"
3125 );
3126 }
3127
3128 #[test]
3131 fn test_dangerous_uri_scheme_with_embedded_tab() {
3132 assert!(has_dangerous_uri_scheme("java\tscript:alert(1)"));
3133 }
3134
3135 #[test]
3136 fn test_dangerous_uri_scheme_with_embedded_newline() {
3137 assert!(has_dangerous_uri_scheme("java\nscript:alert(1)"));
3138 }
3139
3140 #[test]
3141 fn test_dangerous_uri_scheme_with_control_chars() {
3142 assert!(has_dangerous_uri_scheme("java\x00script:alert(1)"));
3143 }
3144
3145 #[test]
3146 fn test_safe_uri_not_flagged() {
3147 assert!(!has_dangerous_uri_scheme("https://example.com"));
3148 }
3149
3150 #[test]
3151 fn test_dangerous_uri_scheme_with_many_embedded_whitespace() {
3152 let payload = "j\ta\tv\ta\ts\tc\tr\ti\tp\tt\t:\ta\tl\te\tr\tt\t(\t1\t)\t";
3155 assert!(
3156 has_dangerous_uri_scheme(payload),
3157 "1 tab between letters should not bypass javascript: detection"
3158 );
3159 }
3160
3161 #[test]
3162 fn test_dangerous_uri_scheme_whitespace_bypass_regression() {
3163 let payload = "j\t\t\ta\t\t\tv\t\t\ta\t\t\ts\t\t\tc\t\t\tr\t\t\ti\t\t\tp\t\t\tt\t\t\t:";
3168 assert!(
3169 has_dangerous_uri_scheme(payload),
3170 "3 tabs between letters (colon at raw position 40) must still be detected"
3171 );
3172 }
3173
3174 #[test]
3177 fn test_svg_section_blocks_multiline_script_tag_splitting() {
3178 let input = "{start_of_svg}\n<script\n>alert(1)</script>\n{end_of_svg}";
3180 let html = render(input);
3181 assert!(
3182 !html.contains("alert(1)"),
3183 "multi-line <script> tag splitting must not execute JS"
3184 );
3185 assert!(
3186 !html.to_lowercase().contains("<script"),
3187 "multi-line <script> tag must be stripped"
3188 );
3189 }
3190
3191 #[test]
3192 fn test_svg_section_blocks_multiline_iframe_tag_splitting() {
3193 let input =
3194 "{start_of_svg}\n<iframe\nsrc=\"javascript:alert(1)\">\n</iframe>\n{end_of_svg}";
3195 let html = render(input);
3196 assert!(
3197 !html.to_lowercase().contains("<iframe"),
3198 "multi-line <iframe> tag splitting must be stripped"
3199 );
3200 assert!(
3201 !html.contains("javascript:"),
3202 "javascript: URI in split iframe must be stripped"
3203 );
3204 }
3205
3206 #[test]
3207 fn test_svg_section_blocks_multiline_foreignobject_splitting() {
3208 let input = "{start_of_svg}\n<foreignObject\n><script>alert(1)</script></foreignObject>\n{end_of_svg}";
3209 let html = render(input);
3210 assert!(
3211 !html.to_lowercase().contains("<foreignobject"),
3212 "multi-line <foreignObject> splitting must be stripped"
3213 );
3214 }
3215}