1use crate::{DetectorRegistry, Error, MermaidConfig, Result};
2use serde_json::Value;
3use std::borrow::Cow;
4
5#[derive(Debug, Clone)]
6pub struct PreprocessResult {
7 pub code: String,
8 pub title: Option<String>,
9 pub config: MermaidConfig,
10}
11
12const FRONTMATTER_DIAGRAM_CONFIG_KEYS: &[&str] = &[
13 "architecture",
14 "block",
15 "c4",
16 "class",
17 "er",
18 "flowchart",
19 "gantt",
20 "gitGraph",
21 "journey",
22 "kanban",
23 "mindmap",
24 "packet",
25 "pie",
26 "quadrantChart",
27 "radar",
28 "requirement",
29 "sankey",
30 "sequence",
31 "state",
32 "timeline",
33 "venn",
34 "xyChart",
35];
36
37const FRONTMATTER_DIAGRAM_CONFIG_ALIASES: &[(&str, &str)] = &[
38 ("classDiagram", "class"),
39 ("erDiagram", "er"),
40 ("stateDiagram", "state"),
41 ("xychart", "xyChart"),
42];
43
44const MAX_CONFIG_NESTING_DEPTH: usize = crate::MAX_DIAGRAM_NESTING_DEPTH;
45
46pub fn preprocess_diagram(input: &str, registry: &DetectorRegistry) -> Result<PreprocessResult> {
47 preprocess_diagram_with_known_type(input, registry, None)
48}
49
50pub fn preprocess_diagram_with_known_type(
51 input: &str,
52 registry: &DetectorRegistry,
53 diagram_type: Option<&str>,
54) -> Result<PreprocessResult> {
55 let cleaned = cleanup_text(input);
56 let (without_frontmatter, title, mut frontmatter_config) =
57 process_frontmatter(cleaned.as_ref())?;
58 let (without_directives, directive_config) =
59 process_directives(without_frontmatter, registry, diagram_type)?;
60
61 frontmatter_config.deep_merge(directive_config.as_value());
62
63 let code = crate::utils::cleanup_mermaid_comments(without_directives.as_ref());
64 Ok(PreprocessResult {
65 code: code.into_owned(),
66 title,
67 config: frontmatter_config,
68 })
69}
70
71fn cleanup_text(input: &str) -> Cow<'_, str> {
72 let mut s: Cow<'_, str> = if input.contains('\r') {
73 Cow::Owned(normalize_crlf(input))
74 } else {
75 Cow::Borrowed(input)
76 };
77
78 if s.contains('#') {
84 s = Cow::Owned(encode_mermaid_entities_like_upstream(s.as_ref()));
85 }
86
87 if s.contains('<') && s.contains("=\"") {
89 s = Cow::Owned(normalize_html_tag_attributes_like_upstream(s.as_ref()));
90 }
91
92 s
93}
94
95fn normalize_crlf(input: &str) -> String {
96 let mut out = String::with_capacity(input.len());
97 let mut chars = input.chars().peekable();
98 while let Some(ch) = chars.next() {
99 if ch == '\r' {
100 out.push('\n');
101 if chars.peek() == Some(&'\n') {
102 chars.next();
103 }
104 } else {
105 out.push(ch);
106 }
107 }
108 out
109}
110
111fn normalize_html_tag_attributes_like_upstream(text: &str) -> String {
112 let mut out = String::with_capacity(text.len());
113 let bytes = text.as_bytes();
114 let mut cursor = 0usize;
115 let mut probe = 0usize;
116
117 while let Some(rel_start) = text[probe..].find('<') {
118 let start = probe + rel_start;
119 let tag_start = start + 1;
120 if tag_start >= bytes.len() || !is_mermaid_js_word_byte(bytes[tag_start]) {
121 probe = tag_start;
122 continue;
123 }
124
125 let mut tag_end = tag_start + 1;
126 while tag_end < bytes.len() && is_mermaid_js_word_byte(bytes[tag_end]) {
127 tag_end += 1;
128 }
129
130 let Some(rel_end) = text[tag_end..].find('>') else {
131 probe = tag_start;
132 continue;
133 };
134 let end = tag_end + rel_end;
135
136 out.push_str(&text[cursor..start]);
137 out.push('<');
138 out.push_str(&text[tag_start..tag_end]);
139 out.push_str(&normalize_html_attributes_like_upstream(
140 &text[tag_end..end],
141 ));
142 out.push('>');
143
144 cursor = end + 1;
145 probe = end + 1;
146 }
147
148 out.push_str(&text[cursor..]);
149 out
150}
151
152fn normalize_html_attributes_like_upstream(attributes: &str) -> String {
153 let mut out = String::with_capacity(attributes.len());
154 let mut cursor = 0usize;
155 let mut probe = 0usize;
156
157 while let Some(rel_start) = attributes[probe..].find("=\"") {
158 let start = probe + rel_start;
159 let value_start = start + 2;
160 let Some(rel_end) = attributes[value_start..].find('"') else {
161 probe = value_start;
162 continue;
163 };
164 let end = value_start + rel_end;
165
166 out.push_str(&attributes[cursor..start]);
167 out.push_str("='");
168 out.push_str(&attributes[value_start..end]);
169 out.push('\'');
170
171 cursor = end + 1;
172 probe = end + 1;
173 }
174
175 out.push_str(&attributes[cursor..]);
176 out
177}
178
179fn encode_mermaid_entities_like_upstream(text: &str) -> String {
180 if !text.contains('#') {
181 return text.to_string();
182 }
183
184 let mut txt = text.to_string();
190
191 if txt.contains("style") && txt.contains(';') {
192 txt = strip_hex_style_semicolons_like_upstream(&txt, "style");
193 }
194
195 if txt.contains("classDef") && txt.contains(';') {
196 txt = strip_hex_style_semicolons_like_upstream(&txt, "classDef");
197 }
198
199 if txt.contains(';') {
200 txt = encode_entity_placeholders_like_upstream(&txt);
201 }
202
203 txt
204}
205
206fn encode_entity_placeholders_like_upstream(text: &str) -> String {
207 let mut out = String::with_capacity(text.len());
208 let bytes = text.as_bytes();
209 let mut cursor = 0usize;
210
211 while let Some(rel_hash) = text[cursor..].find('#') {
212 let start = cursor + rel_hash;
213 let mut end = start + 1;
214 while end < bytes.len() && is_mermaid_js_word_byte(bytes[end]) {
215 end += 1;
216 }
217
218 if end > start + 1 && bytes.get(end) == Some(&b';') {
219 out.push_str(&text[cursor..start]);
220 let inner = &text[start + 1..end];
221 if inner.bytes().all(|b| b.is_ascii_digit()) {
222 out.push_str("fl°°");
223 } else {
224 out.push_str("fl°");
225 }
226 out.push_str(inner);
227 out.push_str("¶ß");
228 cursor = end + 1;
229 } else {
230 out.push_str(&text[cursor..=start]);
231 cursor = start + 1;
232 }
233 }
234
235 out.push_str(&text[cursor..]);
236 out
237}
238
239fn is_mermaid_js_word_byte(byte: u8) -> bool {
240 byte.is_ascii_alphanumeric() || byte == b'_'
241}
242
243fn strip_hex_style_semicolons_like_upstream(text: &str, keyword: &str) -> String {
244 let mut out = String::with_capacity(text.len());
245 let mut line_start = 0usize;
246
247 for (idx, ch) in text.char_indices() {
248 if ch == '\n' {
249 strip_hex_style_semicolons_from_line(&text[line_start..idx], keyword, &mut out);
250 out.push('\n');
251 line_start = idx + ch.len_utf8();
252 }
253 }
254
255 strip_hex_style_semicolons_from_line(&text[line_start..], keyword, &mut out);
256 out
257}
258
259fn strip_hex_style_semicolons_from_line(line: &str, keyword: &str, out: &mut String) {
260 let mut cursor = 0usize;
261 while let Some(semicolon) = find_hex_style_match(line, keyword, cursor) {
262 out.push_str(&line[cursor..semicolon]);
263 cursor = semicolon + 1;
264 }
265 out.push_str(&line[cursor..]);
266}
267
268fn find_hex_style_match(line: &str, keyword: &str, search_start: usize) -> Option<usize> {
269 let mut probe = search_start;
270 while let Some(rel_start) = line[probe..].find(keyword) {
271 let start = probe + rel_start;
272 if let Some(semicolon) = find_hex_style_match_end(line, start + keyword.len()) {
273 return Some(semicolon);
274 }
275 probe = start + keyword.len();
276 }
277 None
278}
279
280fn find_hex_style_match_end(line: &str, search_start: usize) -> Option<usize> {
281 let mut probe = search_start;
282 while let Some(rel_colon) = line[probe..].find(':') {
283 let colon = probe + rel_colon;
284 let mut hash = None;
285 for (rel, ch) in line[colon + 1..].char_indices() {
286 if ch.is_whitespace() {
287 break;
288 }
289 if ch == '#' {
290 hash = Some(colon + 1 + rel);
291 break;
292 }
293 }
294
295 if let Some(hash) = hash {
296 return line[hash + 1..].rfind(';').map(|rel| hash + 1 + rel);
297 }
298
299 probe = colon + 1;
300 }
301 None
302}
303
304fn process_frontmatter(input: &str) -> Result<(&str, Option<String>, MermaidConfig)> {
305 if !input.trim_start().starts_with("---") {
306 return Ok((input, None, MermaidConfig::empty_object()));
307 }
308
309 let Some((yaml_body, stripped)) = split_frontmatter(input) else {
310 return Ok((input, None, MermaidConfig::empty_object()));
311 };
312
313 if config_nesting_exceeds_limit(yaml_body) {
314 return Err(Error::InvalidFrontMatterYaml {
315 message: format!("config nesting exceeds {MAX_CONFIG_NESTING_DEPTH} levels"),
316 });
317 }
318
319 let raw_yaml: serde_yaml::Value =
320 serde_yaml::from_str(yaml_body).map_err(|e| Error::InvalidFrontMatterYaml {
321 message: e.to_string(),
322 })?;
323 let parsed = serde_json::to_value(raw_yaml).unwrap_or(Value::Null);
324 let parsed_obj = match parsed {
325 Value::Object(obj) => obj,
326 other => {
327 crate::config::drop_value_nonrecursive(other);
328 Default::default()
329 }
330 };
331
332 let mut title = None;
333 let mut display_mode = None;
334
335 if let Some(Value::String(t)) = parsed_obj.get("title") {
336 title = Some(t.clone());
337 }
338 if let Some(Value::String(dm)) = parsed_obj.get("displayMode") {
339 display_mode = Some(dm.clone());
340 }
341
342 let mut config = MermaidConfig::empty_object();
343 merge_top_level_frontmatter_diagram_configs(&parsed_obj, &mut config);
344 if let Some(v) = parsed_obj.get("config") {
345 config.deep_merge(v);
346 }
347 crate::config::mirror_legacy_font_family_into_theme_variables(&mut config);
348 if let Some(dm) = display_mode {
349 config.set_value("gantt.displayMode", Value::String(dm));
350 }
351
352 crate::config::drop_value_nonrecursive(Value::Object(parsed_obj));
353 Ok((stripped, title, config))
354}
355
356fn split_frontmatter(input: &str) -> Option<(&str, &str)> {
357 let after_marker = input.strip_prefix("---")?;
358 let open_line_end = after_marker.find('\n')?;
359 if !after_marker[..open_line_end].trim().is_empty() {
360 return None;
361 }
362
363 let body_start = 3 + open_line_end + 1;
364 let rest = &input[body_start..];
365 let mut offset = 0usize;
366
367 for line in rest.split_inclusive('\n') {
368 let without_newline = line.trim_end_matches(['\r', '\n']);
369 if without_newline.trim() == "---" {
370 let body = &rest[..offset];
371 let stripped = &rest[offset + line.len()..];
372 return Some((body, stripped));
373 }
374 offset += line.len();
375 }
376
377 None
378}
379
380fn merge_top_level_frontmatter_diagram_configs(
381 parsed_obj: &serde_json::Map<String, Value>,
382 config: &mut MermaidConfig,
383) {
384 for &(source_key, target_key) in FRONTMATTER_DIAGRAM_CONFIG_ALIASES {
387 if let Some(value) = parsed_obj.get(source_key) {
388 config.set_value(target_key, crate::config::clone_value_nonrecursive(value));
389 }
390 }
391
392 for &key in FRONTMATTER_DIAGRAM_CONFIG_KEYS {
393 if let Some(value) = parsed_obj.get(key) {
394 config.set_value(key, crate::config::clone_value_nonrecursive(value));
395 }
396 }
397}
398
399fn process_directives<'a>(
400 input: &'a str,
401 registry: &DetectorRegistry,
402 diagram_type: Option<&str>,
403) -> Result<(Cow<'a, str>, MermaidConfig)> {
404 let directives = detect_directives(input)?;
405 if directives.is_empty() {
406 return Ok((Cow::Borrowed(input), MermaidConfig::empty_object()));
407 }
408 let init = detect_init(&directives, input, registry, diagram_type)?;
409 let wrap = directives.iter().any(|d| d.ty == "wrap");
410
411 let mut merged = init;
412 if wrap {
413 merged.set_value("wrap", Value::Bool(true));
414 }
415
416 Ok((Cow::Owned(remove_directives(input)), merged))
417}
418
419fn detect_init(
420 directives: &[Directive],
421 input: &str,
422 registry: &DetectorRegistry,
423 diagram_type: Option<&str>,
424) -> Result<MermaidConfig> {
425 let mut merged = MermaidConfig::empty_object();
426 let mut config_for_detect = MermaidConfig::empty_object();
427
428 for d in directives {
429 if d.ty != "init" && d.ty != "initialize" {
430 continue;
431 }
432
433 let mut args = match &d.args {
434 Some(v) => crate::config::clone_value_nonrecursive(v),
435 None => Value::Object(Default::default()),
436 };
437
438 sanitize_directive(&mut args);
439
440 if let Some(diagram_specific) = args
442 .get("config")
443 .map(crate::config::clone_value_nonrecursive)
444 {
445 let detected = diagram_type.map(|t| t.to_string()).or_else(|| {
446 registry
447 .detect_type(input, &mut config_for_detect)
448 .ok()
449 .map(ToString::to_string)
450 });
451
452 if let Some(mut ty) = detected {
453 if ty == "flowchart-v2" {
454 ty = "flowchart".to_string();
455 }
456 if let Value::Object(obj) = &mut args {
457 if let Some(old) = obj.insert(ty, diagram_specific) {
458 crate::config::drop_value_nonrecursive(old);
459 }
460 if let Some(old) = obj.remove("config") {
461 crate::config::drop_value_nonrecursive(old);
462 }
463 }
464 }
465 }
466 crate::config::mirror_legacy_font_family_into_theme_variables_value(&mut args);
467
468 merged.deep_merge(&args);
469 }
470
471 Ok(merged)
472}
473
474#[derive(Debug, Clone)]
475struct Directive {
476 ty: String,
477 args: Option<Value>,
478}
479
480fn detect_directives(input: &str) -> Result<Vec<Directive>> {
481 let mut out = Vec::new();
482 let mut pos = 0;
483 let trimmed = input.trim();
484 if !trimmed.contains("%%{") {
485 return Ok(out);
486 }
487
488 let text = trimmed.replace('\'', "\"");
491
492 while let Some(rel) = text[pos..].find("%%{") {
493 let start = pos + rel;
494 let content_start = start + 3;
495 let Some(rel_end) = text[content_start..].find("}%%") else {
496 break;
497 };
498 let content_end = content_start + rel_end;
499 let raw = text[content_start..content_end].trim();
500
501 if let Some(d) = parse_directive(raw)? {
502 out.push(d);
503 }
504
505 pos = content_end + 3;
506 }
507
508 Ok(out)
509}
510
511#[derive(Clone)]
512enum DirectiveValuePathSegment {
513 Key(String),
514 Index(usize),
515}
516
517fn sanitize_directive(value: &mut Value) {
518 let mut stack = vec![Vec::<DirectiveValuePathSegment>::new()];
519
520 while let Some(path) = stack.pop() {
521 let Some(current) = directive_value_at_path_mut(value, &path) else {
522 continue;
523 };
524
525 match current {
526 Value::Object(map) => {
527 if let Some(old) = map.remove("secure") {
528 crate::config::drop_value_nonrecursive(old);
529 }
530
531 let blocked_keys = map
532 .keys()
533 .filter(|key| key.starts_with("__"))
534 .cloned()
535 .collect::<Vec<_>>();
536 for key in blocked_keys {
537 if let Some(old) = map.remove(&key) {
538 crate::config::drop_value_nonrecursive(old);
539 }
540 }
541
542 let child_keys = map.keys().cloned().collect::<Vec<_>>();
543 for key in child_keys.into_iter().rev() {
544 let mut child_path = path.clone();
545 child_path.push(DirectiveValuePathSegment::Key(key));
546 stack.push(child_path);
547 }
548 }
549 Value::Array(arr) => {
550 for idx in (0..arr.len()).rev() {
551 let mut child_path = path.clone();
552 child_path.push(DirectiveValuePathSegment::Index(idx));
553 stack.push(child_path);
554 }
555 }
556 Value::String(s) => {
557 let blocked = s.contains('<') || s.contains('>') || s.contains("url(data:");
558 if blocked {
559 s.clear();
560 }
561 }
562 _ => {}
563 }
564 }
565}
566
567fn directive_value_at_path_mut<'a>(
568 mut value: &'a mut Value,
569 path: &[DirectiveValuePathSegment],
570) -> Option<&'a mut Value> {
571 for segment in path {
572 match segment {
573 DirectiveValuePathSegment::Key(key) => {
574 value = value.as_object_mut()?.get_mut(key)?;
575 }
576 DirectiveValuePathSegment::Index(idx) => {
577 value = value.as_array_mut()?.get_mut(*idx)?;
578 }
579 }
580 }
581 Some(value)
582}
583
584fn remove_directives(text: &str) -> String {
585 let mut out = String::with_capacity(text.len());
586 let mut pos = 0;
587 while let Some(rel) = text[pos..].find("%%{") {
588 let start = pos + rel;
589 out.push_str(&text[pos..start]);
590 let after_start = start + 3;
591 if let Some(rel_end) = text[after_start..].find("}%%") {
592 let end = after_start + rel_end + 3;
593 pos = end;
594 } else {
595 return out;
596 }
597 }
598 out.push_str(&text[pos..]);
599 out
600}
601
602fn parse_directive(raw: &str) -> Result<Option<Directive>> {
603 let raw = raw.trim();
604 if raw.is_empty() {
605 return Ok(None);
606 }
607
608 let mut chars = raw.chars().peekable();
609 let mut ty = String::new();
610 while let Some(&c) = chars.peek() {
611 if c.is_ascii_alphanumeric() || c == '_' {
612 ty.push(c);
613 chars.next();
614 continue;
615 }
616 break;
617 }
618 if ty.is_empty() {
619 return Ok(None);
620 }
621
622 while matches!(chars.peek(), Some(c) if c.is_whitespace()) {
623 chars.next();
624 }
625
626 let args = if matches!(chars.peek(), Some(':')) {
627 chars.next();
628 while matches!(chars.peek(), Some(c) if c.is_whitespace()) {
629 chars.next();
630 }
631 let rest: String = chars.collect();
632 let rest = rest.trim();
633 if rest.is_empty() {
634 None
635 } else if rest.starts_with('{') || rest.starts_with('[') {
636 if config_nesting_exceeds_limit(rest) {
637 return Err(Error::InvalidDirectiveJson {
638 message: format!("config nesting exceeds {MAX_CONFIG_NESTING_DEPTH} levels"),
639 });
640 }
641 Some(
642 json5::from_str::<Value>(rest).map_err(|e| Error::InvalidDirectiveJson {
643 message: e.to_string(),
644 })?,
645 )
646 } else {
647 Some(Value::String(rest.to_string()))
648 }
649 } else {
650 None
651 };
652
653 Ok(Some(Directive { ty, args }))
654}
655
656fn config_nesting_exceeds_limit(text: &str) -> bool {
657 max_flow_collection_depth(text) > MAX_CONFIG_NESTING_DEPTH
658 || max_yaml_indent_depth(text) > MAX_CONFIG_NESTING_DEPTH
659}
660
661fn max_flow_collection_depth(text: &str) -> usize {
662 let mut max_depth = 0usize;
663 let mut depth = 0usize;
664 let mut quote = None;
665 let mut escaped = false;
666
667 for ch in text.chars() {
668 if let Some(q) = quote {
669 if escaped {
670 escaped = false;
671 continue;
672 }
673 if ch == '\\' {
674 escaped = true;
675 continue;
676 }
677 if ch == q {
678 quote = None;
679 }
680 continue;
681 }
682
683 match ch {
684 '"' | '\'' => quote = Some(ch),
685 '{' | '[' => {
686 depth = depth.saturating_add(1);
687 max_depth = max_depth.max(depth);
688 }
689 '}' | ']' => {
690 depth = depth.saturating_sub(1);
691 }
692 _ => {}
693 }
694 }
695
696 max_depth
697}
698
699fn max_yaml_indent_depth(text: &str) -> usize {
700 let mut indents = Vec::<usize>::new();
701 let mut max_depth = 0usize;
702
703 for line in text.lines() {
704 let trimmed = line.trim();
705 if trimmed.is_empty() || trimmed.starts_with('#') {
706 continue;
707 }
708
709 let indent = line.len() - line.trim_start_matches(' ').len();
710 while indents.last().is_some_and(|prev| indent <= *prev) {
711 indents.pop();
712 }
713 indents.push(indent);
714 let inline_sequence_depth = yaml_inline_sequence_indicator_count(trimmed);
715 max_depth = max_depth.max(indents.len() + inline_sequence_depth.saturating_sub(1));
716 }
717
718 max_depth
719}
720
721fn yaml_inline_sequence_indicator_count(mut text: &str) -> usize {
722 let mut count = 0usize;
723 loop {
724 let Some(after_dash) = text.strip_prefix('-') else {
725 return count;
726 };
727 if after_dash
728 .chars()
729 .next()
730 .is_some_and(|ch| !ch.is_whitespace())
731 {
732 return count;
733 }
734 count += 1;
735 text = after_dash.trim_start();
736 }
737}
738
739#[cfg(test)]
740mod tests {
741 use super::*;
742 use serde_json::Map;
743
744 #[test]
745 fn normalize_crlf_matches_mermaid_line_ending_cleanup() {
746 assert_eq!(
747 normalize_crlf("flowchart TD\r\nA-->B\rC-->D\n"),
748 "flowchart TD\nA-->B\nC-->D\n"
749 );
750 assert_eq!(normalize_crlf("\r\r\n\n"), "\n\n\n");
751 }
752
753 #[test]
754 fn normalize_html_tag_attributes_matches_mermaid_cleanup_shape() {
755 assert_eq!(
756 normalize_html_tag_attributes_like_upstream(
757 r#"<span title="A" data-empty="">Label</span><br disabled="yes">"#
758 ),
759 r#"<span title='A' data-empty=''>Label</span><br disabled='yes'>"#
760 );
761 assert_eq!(
762 normalize_html_tag_attributes_like_upstream(r#"<é title="A"><_x value="B"><1 n="C">"#),
763 r#"<é title="A"><_x value='B'><1 n='C'>"#
764 );
765 assert_eq!(
766 normalize_html_tag_attributes_like_upstream(r#"<span a="x" title="A>B">"#),
767 r#"<span a='x' title="A>B">"#
768 );
769 assert_eq!(
770 normalize_html_tag_attributes_like_upstream(r#"<<span title="A">"#),
771 r#"<<span title='A'>"#
772 );
773 }
774
775 #[test]
776 fn encode_entity_placeholders_matches_mermaid_ascii_word_shape() {
777 assert_eq!(
778 encode_mermaid_entities_like_upstream("Hello #there; #andHere;#77653;"),
779 "Hello fl°there¶ß fl°andHere¶ßfl°°77653¶ß"
780 );
781 assert_eq!(
782 encode_mermaid_entities_like_upstream(
783 "style this; is ; everything :something#not-nothing; and this too;"
784 ),
785 "style this; is ; everything :something#not-nothing; and this too"
786 );
787 assert_eq!(
788 encode_mermaid_entities_like_upstream(
789 "classDef this; is ; everything :something#not-nothing; and this too;"
790 ),
791 "classDef this; is ; everything :something#not-nothing; and this too"
792 );
793 assert_eq!(
794 encode_mermaid_entities_like_upstream("style a fill:#fff; style b fill:#000;"),
795 "style a fill:fl°fff¶ß style b fill:#000"
796 );
797 assert_eq!(
798 encode_mermaid_entities_like_upstream("style a fill: #fff;"),
799 "style a fill: fl°fff¶ß"
800 );
801 assert_eq!(
802 encode_mermaid_entities_like_upstream("#é; #+123; #has-dash;"),
803 "#é; #+123; #has-dash;"
804 );
805 }
806
807 #[test]
808 fn sanitize_directive_handles_deep_values_with_small_stack() {
809 const DEPTH: usize = 2_048;
810 let mut value = deep_directive_value(DEPTH, Value::String("<blocked>".to_string()));
811
812 let handle = std::thread::Builder::new()
813 .name("preprocess-deep-directive-sanitize".to_string())
814 .stack_size(64 * 1024)
815 .spawn(move || {
816 sanitize_directive(&mut value);
817 assert_eq!(
818 deep_directive_leaf(&value, DEPTH).and_then(Value::as_str),
819 Some("")
820 );
821 crate::config::drop_value_nonrecursive(value);
822 })
823 .expect("spawn deep directive sanitizer test");
824 handle
825 .join()
826 .expect("deep directive sanitizer should finish without stack overflow");
827 }
828
829 #[test]
830 fn config_nesting_counts_inline_yaml_sequence_indicators() {
831 let yaml = format!(
832 "config:\n {}\"leaf\"",
833 "- ".repeat(MAX_CONFIG_NESTING_DEPTH + 1)
834 );
835 assert!(config_nesting_exceeds_limit(&yaml));
836 }
837
838 fn deep_directive_value(depth: usize, leaf: Value) -> Value {
839 let mut value = leaf;
840 for idx in (0..depth).rev() {
841 let mut map = Map::new();
842 map.insert(format!("k{idx}"), value);
843 value = Value::Object(map);
844 }
845 value
846 }
847
848 fn deep_directive_leaf(mut value: &Value, depth: usize) -> Option<&Value> {
849 for idx in 0..depth {
850 value = value.as_object()?.get(&format!("k{idx}"))?;
851 }
852 Some(value)
853 }
854}