Skip to main content

rumdl_lib/utils/anchor_styles/
github.rs

1//! GitHub.com official anchor generation with security hardening
2//!
3//! This module implements the exact anchor generation algorithm used by GitHub.com,
4//! verified through comprehensive testing with GitHub Gists, with comprehensive
5//! security hardening against injection attacks and DoS vectors.
6//!
7//! Algorithm verified against GitHub.com (not third-party packages):
8//! 1. Input validation and size limits (max 10KB)
9//! 2. Unicode normalization (NFC) to prevent homograph attacks
10//! 3. Dangerous Unicode filtering (RTL override, zero-width, control chars)
11//! 4. Lowercase conversion
12//! 5. Markdown formatting removal (*, `, []) with ReDoS-safe patterns
13//! 6. Multi-character pattern replacement (-->, <->, ==>, ->)
14//! 7. Special symbol replacement (& → --, © → --)
15//! 8. Character processing (preserve letters, digits, underscores, hyphens)
16//! 9. Space → single hyphen, emojis → single hyphen
17//! 10. No leading/trailing trimming (unlike kramdown)
18//!
19//! Security measures implemented:
20//! - Input size limits to prevent memory exhaustion
21//! - Unicode normalization to prevent homograph attacks
22//! - Bidirectional text injection prevention
23//! - Zero-width character stripping
24//! - Control character filtering
25//! - ReDoS-resistant regex patterns with complexity limits
26//! - Comprehensive emoji detection including country flags and keycaps
27
28use regex::Regex;
29use std::sync::LazyLock;
30use unicode_normalization::UnicodeNormalization;
31
32use super::common::{
33    DANGEROUS_UNICODE_PATTERN, MAX_INPUT_LENGTH, UnicodeLetterMode, ZERO_WIDTH_PATTERN, is_safe_unicode_letter,
34};
35
36// ReDoS-resistant patterns with atomic grouping and possessive quantifiers where possible
37// Limited repetition depth to prevent catastrophic backtracking
38// Match both asterisk and underscore emphasis (with proper nesting handling)
39static EMPHASIS_ASTERISK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*{1,3}([^*]+?)\*{1,3}").unwrap());
40// Match emphasis underscores - only when they wrap text, not in snake_case
41// This pattern matches _text_ or __text__ but not test_with_underscores
42static EMPHASIS_UNDERSCORE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\b_{1,2}([^_\s][^_]*?)_{1,2}\b").unwrap());
43// Match both single-backtick and double-backtick code spans.
44// Double-backtick spans (``code``) are tried first so they aren't consumed as two single spans.
45static CODE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"``([^`]{0,500})``|`([^`]{0,500})`").unwrap());
46// Match image and link patterns
47// Using simple approach: match the brackets and parentheses, extract only the bracket content
48static IMAGE_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!\[([^\]]*)\]\([^)]*\)").unwrap());
49static LINK_PATTERN: LazyLock<Regex> =
50    LazyLock::new(|| Regex::new(r"\[([^\[\]]*(?:\[[^\[\]]*\][^\[\]]*)*)\](?:\([^)]*\)|\[[^\]]*\])").unwrap());
51
52// Ampersand and copyright with whitespace patterns
53static AMPERSAND_WITH_SPACES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+&\s+").unwrap());
54static COPYRIGHT_WITH_SPACES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+©\s+").unwrap());
55
56// HTML/JSX tag stripping - GitHub removes entire HTML tags from heading anchors
57// Matches opening tags (<div>, <Component />), closing tags (</div>), and self-closing tags
58// Requires first char after < to be a letter or / (to avoid matching arrow patterns like <->)
59// Uses case-insensitive flag since this is applied after lowercasing
60static HTML_TAG_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)</?[a-z][^>]*>").unwrap());
61
62// HTML comment stripping - GitHub renders `<!-- ... -->` invisibly and never includes
63// its content in anchor IDs. Stripped before arrow-pattern processing so a comment's
64// trailing `-->` is never misinterpreted as an arrow.
65// Non-greedy and newline-free: headings are single-line, and `.` excludes `\n` by default.
66static HTML_COMMENT_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<!--.*?-->").unwrap());
67
68/// Generate GitHub.com style anchor fragment from heading text with security hardening
69///
70/// This implementation matches GitHub.com's exact behavior, verified through
71/// comprehensive testing with GitHub Gists, while providing robust security
72/// against various injection and DoS attacks.
73///
74/// # Security Features
75/// - Input size limits (max 10KB) to prevent memory exhaustion
76/// - Unicode normalization (NFC) to prevent homograph attacks
77/// - Bidirectional text injection filtering
78/// - Zero-width character removal
79/// - Control character filtering
80/// - ReDoS-resistant regex patterns
81/// - Comprehensive emoji detection
82///
83/// # Examples
84/// ```
85/// use rumdl_lib::utils::anchor_styles::github;
86///
87/// assert_eq!(github::heading_to_fragment("Hello World"), "hello-world");
88/// assert_eq!(github::heading_to_fragment("cbrown --> sbrown: --unsafe-paths"), "cbrown----sbrown---unsafe-paths");
89/// assert_eq!(github::heading_to_fragment("test_with_underscores"), "test_with_underscores");
90/// ```
91pub fn heading_to_fragment(heading: &str) -> String {
92    // Security Step 1: Input validation and size limits
93    if heading.is_empty() {
94        return String::new();
95    }
96
97    if heading.len() > MAX_INPUT_LENGTH {
98        // Truncate oversized input to prevent memory exhaustion
99        // Use char_indices to ensure we don't split in the middle of a UTF-8 character
100        let mut truncated_len = 0;
101        for (byte_index, _) in heading.char_indices() {
102            if byte_index >= MAX_INPUT_LENGTH {
103                truncated_len = byte_index;
104                break;
105            }
106            truncated_len = byte_index + 1; // Include the current character
107        }
108        if truncated_len == 0 {
109            truncated_len = MAX_INPUT_LENGTH.min(heading.len());
110        }
111        let truncated = &heading[..truncated_len];
112        return heading_to_fragment_internal(truncated);
113    }
114
115    heading_to_fragment_internal(heading)
116}
117
118/// Internal implementation with security hardening
119fn heading_to_fragment_internal(heading: &str) -> String {
120    // Security Step 2: Unicode normalization to prevent homograph attacks
121    // NFC normalization ensures canonical representation
122    let normalized: String = heading.nfc().collect();
123
124    // Step 3: Handle emoji sequences BEFORE sanitizing ZWJ
125    // This preserves multi-component emojis and keycaps
126    // Quick optimization: skip if clearly no emojis (common case)
127    let emoji_processed = if normalized.chars().any(|c| {
128        let code = c as u32;
129        // Quick check for common emoji ranges
130        (0x1F300..=0x1F9FF).contains(&code) || // Most emojis
131        (0x2600..=0x26FF).contains(&code) ||   // Misc symbols
132        (0x1F1E6..=0x1F1FF).contains(&code) // Regional indicators
133    }) {
134        process_emoji_sequences(&normalized)
135    } else {
136        normalized
137    };
138
139    // Security Step 4: Filter dangerous Unicode characters
140    let sanitized = sanitize_unicode(&emoji_processed);
141
142    // Step 5: Convert to lowercase
143    let mut text = sanitized.to_lowercase();
144
145    // Step 5a: Strip HTML comments before any other processing.
146    // Comments are invisible on GitHub and must not contribute to the anchor.
147    // Stripping before arrow handling prevents `-->` inside a comment from
148    // being misread as an arrow pattern.
149    if text.contains("<!--") {
150        text = HTML_COMMENT_PATTERN.replace_all(&text, "").to_string();
151    }
152
153    // Step 5: Remove markdown formatting while preserving inner text
154    if text.contains('*') || text.contains('_') || text.contains('`') || text.contains('[') {
155        // Extract code span content FIRST and protect it from emphasis processing.
156        // Code spans take precedence over emphasis in Markdown parsing, so
157        // `__init__` should preserve underscores (literal code content),
158        // while __init__ without backticks should strip them (emphasis).
159        let mut code_extracts: Vec<String> = Vec::new();
160        text = CODE_PATTERN
161            .replace_all(&text, |caps: &regex::Captures| {
162                let idx = code_extracts.len();
163                // Group 1 is the double-backtick match, group 2 is the single-backtick match
164                let content = caps.get(1).or_else(|| caps.get(2)).map_or("", |m| m.as_str());
165                code_extracts.push(content.to_string());
166                format!("\x00CODE{idx}\x00")
167            })
168            .to_string();
169
170        // Process emphasis iteratively to handle nesting (e.g., **_text_**)
171        // Bounded to 3 iterations to prevent infinite loops on malformed input
172        for _ in 0..3 {
173            let prev = text.clone();
174            text = EMPHASIS_ASTERISK.replace_all(&text, "$1").to_string();
175            text = EMPHASIS_UNDERSCORE.replace_all(&text, "$1").to_string();
176            if text == prev {
177                break;
178            }
179        }
180
181        // Strip HTML/JSX tags BEFORE restoring code spans
182        // Code spans are still protected as placeholders, so their angle brackets are safe
183        text = HTML_TAG_PATTERN.replace_all(&text, "").to_string();
184
185        // Restore code span content after HTML tag stripping
186        // Angle brackets from code spans (e.g., `import <FILE>`) are preserved as plain text
187        for (idx, content) in code_extracts.into_iter().enumerate() {
188            text = text.replace(&format!("\x00CODE{idx}\x00"), &content);
189        }
190
191        text = IMAGE_PATTERN.replace_all(&text, "$1").to_string();
192        text = LINK_PATTERN.replace_all(&text, "$1").to_string();
193    } else if text.contains('<') {
194        // Strip HTML/JSX tags even when no markdown formatting is present
195        text = HTML_TAG_PATTERN.replace_all(&text, "").to_string();
196    }
197
198    // Step 6: Multi-character arrow patterns (order matters!)
199    // GitHub.com converts these patterns to specific hyphen sequences
200    // Verified against GitHub.com actual behavior (issue #82)
201    // Pattern: arrow itself becomes N hyphens, each adjacent space adds 1 more
202    // Must handle patterns with most spaces first to avoid partial replacements
203
204    // --> patterns (arrow = 2 hyphens base)
205    text = text.replace(" --> ", "----"); // 2 + 1 + 1 = 4 hyphens
206    text = text.replace(" -->", "---"); // 2 + 1 = 3 hyphens
207    text = text.replace("--> ", "---"); // 2 + 1 = 3 hyphens
208    text = text.replace("-->", "--"); // 2 hyphens
209
210    // <-> patterns (assuming similar pattern, needs verification)
211    text = text.replace(" <-> ", "---"); // estimated: 1 + 1 + 1 = 3 hyphens
212    text = text.replace(" <->", "--"); // estimated: 1 + 1 = 2 hyphens
213    text = text.replace("<-> ", "--"); // estimated: 1 + 1 = 2 hyphens
214    text = text.replace("<->", "-"); // estimated: 1 hyphen
215
216    // ==> patterns (assuming similar pattern, needs verification)
217    text = text.replace(" ==> ", "--"); // estimated pattern
218    text = text.replace(" ==>", "-"); // estimated pattern
219    text = text.replace("==> ", "-"); // estimated pattern
220    text = text.replace("==>", ""); // estimated: might be removed entirely
221
222    // -> patterns (arrow = 1 hyphen base)
223    text = text.replace(" -> ", "---"); // 1 + 1 + 1 = 3 hyphens
224    text = text.replace(" ->", "--"); // 1 + 1 = 2 hyphens
225    text = text.replace("-> ", "--"); // 1 + 1 = 2 hyphens
226    text = text.replace("->", "-"); // 1 hyphen
227
228    // Step 7: Remove problematic characters before symbol replacement
229    // First remove em-dashes and en-dashes entirely
230    text = text.replace(['–', '—'], "");
231
232    // Step 8: Emojis were already replaced with hyphens in process_emoji_sequences
233    // No further processing needed for emoji markers
234
235    // Step 9: Special symbol replacements
236    // Handle ampersand based on position and surrounding spaces
237    // GitHub's behavior:
238    // - "& text" at start → "--text"
239    // - "text &" at end → "text-"
240    // - "text & text" in middle → "text--text"
241    // - "&text" (no space) → "text"
242
243    // First handle ampersand at start with space
244    if text.starts_with("& ") {
245        text = text.replacen("& ", "--", 1);
246    }
247    // Then handle ampersand at end with space
248    else if text.ends_with(" &") {
249        text = text[..text.len() - 2].to_string() + "-";
250    }
251    // Then handle ampersand with spaces on both sides
252    else {
253        text = AMPERSAND_WITH_SPACES.replace_all(&text, "--").to_string();
254    }
255
256    // Handle copyright similarly
257    text = COPYRIGHT_WITH_SPACES.replace_all(&text, "--").to_string();
258
259    // Remove ampersand and copyright without spaces
260    text = text.replace('&', "");
261    text = text.replace("©", "");
262
263    // Step 9.5: Remaining angle brackets (from code span content) are handled
264    // during character-by-character processing below - '<' and '>' are simply removed
265    // while their content is preserved as regular text
266
267    // Step 10: Character-by-character processing
268    let mut result = String::with_capacity(text.len()); // Pre-allocate for efficiency
269
270    for c in text.chars() {
271        let code = c as u32;
272        if c.is_ascii_alphabetic() || c.is_ascii_digit() || c == '_' || c == '-' {
273            // Preserve letters, numbers, underscores, and hyphens
274            result.push(c);
275        } else if c == '§' {
276            // Preserve our marker character
277            result.push(c);
278        } else if code == 0x20E3 {
279            // Preserve combining keycap for keycap sequences
280            // Note: FE0F should only be preserved as part of a keycap, not standalone
281            // The keycap preservation is handled in process_emoji_sequences
282            result.push(c);
283        } else if code == 0xFE0F {
284            // Only preserve variation selector if it's preceded by a keycap base
285            if let Some(prev) = result.chars().last()
286                && is_keycap_base(prev)
287            {
288                result.push(c);
289            }
290            // Otherwise filter it out
291        } else if c.is_alphabetic() && is_safe_unicode_letter(c, UnicodeLetterMode::GitHub) {
292            // Preserve Unicode letters (like é, ñ, etc.) but only safe ones
293            result.push(c);
294        } else if c.is_numeric() {
295            // Preserve all numeric characters (digits from any script)
296            result.push(c);
297        } else if c.is_whitespace() {
298            // Convert each whitespace character to a hyphen
299            // GitHub preserves multiple spaces as multiple hyphens
300            result.push('-');
301        }
302        // ASCII punctuation is removed (no replacement)
303        // Unicode symbols have already been handled above
304    }
305
306    // GitHub does NOT trim leading/trailing hyphens, even those from symbol removal
307    // "---leading" → "---leading"
308    // "© 2024" → "-2024"
309    // "trailing---" → "trailing---"
310
311    // Step 11: Replace emoji markers with the correct number of hyphens
312    // Note: markers are lowercase after the lowercasing step above
313    // GitHub's behavior:
314    // - Single emoji at start: "-"
315    // - Single emoji at end: "-"
316    // - Single emoji between words: "--"
317    // - Multiple emojis with spaces: n+1 hyphens
318
319    // Quick check: if no emoji markers, skip processing entirely
320    if !result.contains("§emoji§") {
321        return result;
322    }
323
324    // Simple two-step approach for better performance
325    let mut final_result = result;
326
327    // First, handle multiple consecutive markers (n markers → n+1 hyphens)
328    // Process from longest to shortest to avoid partial replacements
329    for count in (2..=10).rev() {
330        if final_result.contains("§emoji§") {
331            let marker_seq = "§emoji§".repeat(count);
332            if final_result.contains(&marker_seq) {
333                let replacement = "-".repeat(count + 1);
334                final_result = final_result.replace(&marker_seq, &replacement);
335            }
336        }
337    }
338
339    // Then handle single markers based on position
340    if final_result.contains("§emoji§") {
341        let bytes = final_result.as_bytes();
342        let marker = "§emoji§".as_bytes();
343        let mut result_bytes = Vec::with_capacity(bytes.len());
344        let mut i = 0;
345
346        while i < bytes.len() {
347            if i + marker.len() <= bytes.len() && &bytes[i..i + marker.len()] == marker {
348                // Found a marker - check position
349                let at_start = i == 0;
350                let at_end = i + marker.len() >= bytes.len();
351
352                if at_start || at_end {
353                    result_bytes.push(b'-');
354                } else {
355                    result_bytes.extend_from_slice(b"--");
356                }
357                i += marker.len();
358            } else {
359                result_bytes.push(bytes[i]);
360                i += 1;
361            }
362        }
363
364        final_result = String::from_utf8(result_bytes).unwrap_or(final_result);
365    }
366
367    final_result
368}
369
370/// Process emoji sequences before sanitization
371/// Handles multi-component emojis, keycaps, and flags as units
372/// GitHub's behavior: consecutive symbols with spaces between them become n+1 hyphens
373fn process_emoji_sequences(input: &str) -> String {
374    let mut result = String::with_capacity(input.len());
375    let mut chars = input.chars().peekable();
376
377    while let Some(c) = chars.next() {
378        // Check if this starts a symbol/emoji sequence
379        if is_emoji_or_symbol(c) || is_regional_indicator(c) {
380            // Remove preceding space if any
381            if result.ends_with(' ') {
382                result.pop();
383            }
384
385            // Count symbols in this sequence (separated by single spaces)
386            let mut symbol_count = 1;
387
388            // Handle the current symbol
389            // If it's a regional indicator pair (flag)
390            if is_regional_indicator(c) {
391                if let Some(&next) = chars.peek()
392                    && is_regional_indicator(next)
393                {
394                    chars.next(); // Consume second part of flag
395                }
396            }
397            // If it's an emoji with ZWJ sequences
398            else if is_emoji_or_symbol(c) {
399                // Consume the entire emoji sequence including ZWJs
400                while let Some(&next) = chars.peek() {
401                    if next as u32 == 0x200D {
402                        // ZWJ
403                        chars.next();
404                        // After ZWJ, expect another emoji component
405                        if let Some(&emoji) = chars.peek() {
406                            if is_emoji_or_symbol(emoji) || is_regional_indicator(emoji) {
407                                chars.next();
408                            } else {
409                                break;
410                            }
411                        }
412                    } else if next as u32 == 0xFE0F {
413                        // Variation selector
414                        chars.next();
415                    } else if is_emoji_or_symbol(next) || is_regional_indicator(next) {
416                        // Adjacent symbols without spaces are treated as a single unit
417                        // Don't increment symbol_count, just consume them
418                        chars.next();
419                        // Handle multi-part adjacent symbols
420                        if is_regional_indicator(next)
421                            && let Some(&next2) = chars.peek()
422                            && is_regional_indicator(next2)
423                        {
424                            chars.next();
425                        }
426                    } else {
427                        break;
428                    }
429                }
430            }
431
432            // Look for more symbols separated by single spaces
433            while let Some(&next) = chars.peek() {
434                if next == ' ' {
435                    // Peek ahead to see if there's a symbol after the space
436                    let mut temp_chars = chars.clone();
437                    temp_chars.next(); // Skip the space
438                    if let Some(&after_space) = temp_chars.peek() {
439                        if is_emoji_or_symbol(after_space) || is_regional_indicator(after_space) {
440                            // Consume the space and the symbol
441                            chars.next(); // Space
442                            let symbol = chars.next().unwrap(); // Symbol
443                            symbol_count += 1;
444
445                            // Handle multi-part symbols
446                            if is_regional_indicator(symbol) {
447                                if let Some(&next) = chars.peek()
448                                    && is_regional_indicator(next)
449                                {
450                                    chars.next();
451                                }
452                            } else if is_emoji_or_symbol(symbol) {
453                                // Handle ZWJ sequences
454                                while let Some(&next) = chars.peek() {
455                                    if next as u32 == 0x200D {
456                                        // ZWJ
457                                        chars.next();
458                                        if let Some(&emoji) = chars.peek() {
459                                            if is_emoji_or_symbol(emoji) || is_regional_indicator(emoji) {
460                                                chars.next();
461                                            } else {
462                                                break;
463                                            }
464                                        }
465                                    } else if next as u32 == 0xFE0F {
466                                        chars.next();
467                                    } else {
468                                        break;
469                                    }
470                                }
471                            }
472                        } else {
473                            break; // Not a symbol after space
474                        }
475                    } else {
476                        break; // Nothing after space
477                    }
478                } else {
479                    break; // Not a space
480                }
481            }
482
483            // Skip trailing space if any
484            if let Some(&next) = chars.peek()
485                && next == ' '
486            {
487                chars.next();
488            }
489
490            // Generate markers based on symbol count
491            // GitHub's pattern: n symbols with spaces = n+1 hyphens
492            // We use markers that will be replaced with the correct number of hyphens
493            result.push_str("§EMOJI§");
494            // Add extra markers for each additional symbol that was separated by spaces
495            for _ in 1..symbol_count {
496                result.push_str("§EMOJI§");
497            }
498        }
499        // Check for keycap sequences - these should be PRESERVED
500        else if is_keycap_base(c) {
501            let mut keycap_seq = String::new();
502            keycap_seq.push(c);
503
504            // Check for variation selector and/or combining keycap
505            let mut has_keycap = false;
506            while let Some(&next) = chars.peek() {
507                if next as u32 == 0xFE0F || next as u32 == 0x20E3 {
508                    keycap_seq.push(next);
509                    chars.next();
510                    if next as u32 == 0x20E3 {
511                        has_keycap = true;
512                        break;
513                    }
514                } else {
515                    break;
516                }
517            }
518
519            if has_keycap {
520                // Preserve the entire keycap sequence
521                result.push_str(&keycap_seq);
522            } else {
523                // Not a keycap, just push the original character
524                result.push(c);
525                // Push back any variation selectors we consumed
526                for ch in keycap_seq.chars().skip(1) {
527                    result.push(ch);
528                }
529            }
530        } else {
531            // Regular character
532            result.push(c);
533        }
534    }
535
536    result
537}
538
539/// Sanitize Unicode input by removing dangerous character categories
540/// Filters out bidirectional text injection, zero-width chars, and control chars
541fn sanitize_unicode(input: &str) -> String {
542    // Remove zero-width characters that can be used for injection attacks
543    let no_zero_width = ZERO_WIDTH_PATTERN.replace_all(input, "");
544
545    // Remove dangerous RTL override and bidirectional control characters
546    let no_bidi_attack = DANGEROUS_UNICODE_PATTERN.replace_all(&no_zero_width, "");
547
548    // Filter out control characters (except basic whitespace)
549    let mut sanitized = String::with_capacity(no_bidi_attack.len());
550    for c in no_bidi_attack.chars() {
551        if !c.is_control() || c.is_whitespace() {
552            sanitized.push(c);
553        }
554        // Skip control characters entirely for security
555    }
556
557    sanitized
558}
559
560/// Comprehensive emoji and symbol detection
561/// Covers all major emoji ranges including newer additions and symbols
562fn is_emoji_or_symbol(c: char) -> bool {
563    let code = c as u32;
564
565    // Exclude dangerous unicode characters that should be filtered, not replaced
566    // These include bidirectional overrides, zero-width chars, etc.
567    if (0x202A..=0x202E).contains(&code) ||  // Bidirectional formatting
568       (0x2066..=0x2069).contains(&code) ||  // Isolate formatting
569       (0x200B..=0x200D).contains(&code) ||  // Zero-width chars
570       (0x200E..=0x200F).contains(&code) ||  // LTR/RTL marks
571       code == 0x061C ||                     // Arabic Letter Mark
572       code == 0x2060 ||                     // Word Joiner
573       code == 0xFEFF
574    {
575        // Zero Width No-Break Space
576        return false;
577    }
578
579    // Core emoji ranges
580    (0x1F600..=0x1F64F).contains(&code) ||  // Emoticons
581    (0x1F300..=0x1F5FF).contains(&code) ||  // Miscellaneous Symbols and Pictographs
582    (0x1F680..=0x1F6FF).contains(&code) ||  // Transport and Map Symbols
583    (0x1F700..=0x1F77F).contains(&code) ||  // Alchemical Symbols
584    (0x1F780..=0x1F7FF).contains(&code) ||  // Geometric Shapes Extended
585    (0x1F800..=0x1F8FF).contains(&code) ||  // Supplemental Arrows-C
586    (0x1F900..=0x1F9FF).contains(&code) ||  // Supplemental Symbols and Pictographs
587    (0x1FA00..=0x1FA6F).contains(&code) ||  // Chess Symbols
588    (0x1FA70..=0x1FAFF).contains(&code) ||  // Symbols and Pictographs Extended-A
589    (0x1FB00..=0x1FBFF).contains(&code) ||  // Symbols for Legacy Computing
590
591    // Symbol ranges that should be removed
592    (0x2600..=0x26FF).contains(&code) ||    // Miscellaneous Symbols
593    (0x2700..=0x27BF).contains(&code) ||    // Dingbats
594    (0x2B00..=0x2BFF).contains(&code) ||    // Miscellaneous Symbols and Arrows
595    (0x1F000..=0x1F02F).contains(&code) ||  // Mahjong Tiles
596    (0x1F030..=0x1F09F).contains(&code) ||  // Domino Tiles
597    (0x1F0A0..=0x1F0FF).contains(&code) ||  // Playing Cards
598
599    // Additional symbol ranges
600    (0x2190..=0x21FF).contains(&code) ||    // Arrows
601    (0x2200..=0x22FF).contains(&code) ||    // Mathematical Operators
602    (0x2300..=0x23FF).contains(&code) ||    // Miscellaneous Technical
603    (0x2400..=0x243F).contains(&code) ||    // Control Pictures
604    (0x2440..=0x245F).contains(&code) ||    // Optical Character Recognition
605    (0x25A0..=0x25FF).contains(&code) ||    // Geometric Shapes
606    (0x2000..=0x206F).contains(&code) ||    // General Punctuation (includes dangerous chars)
607
608    // Combining marks used in emoji (but not variation selectors - those are handled separately)
609    (0x20D0..=0x20FF).contains(&code) // Combining Diacritical Marks for Symbols
610}
611
612/// Check if character is a regional indicator (used for country flags)
613fn is_regional_indicator(c: char) -> bool {
614    let code = c as u32;
615    (0x1F1E6..=0x1F1FF).contains(&code) // Regional Indicator Symbol letters A-Z
616}
617
618/// Check if character can be the base of a keycap sequence
619fn is_keycap_base(c: char) -> bool {
620    let code = c as u32;
621    // Digits 0-9, *, #, and some letters used in keycap sequences
622    (0x0030..=0x0039).contains(&code) ||  // ASCII digits 0-9
623    code == 0x002A ||                     // Asterisk *
624    code == 0x0023 // Number sign #
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    #[test]
632    fn test_github_basic_cases() {
633        assert_eq!(heading_to_fragment("Hello World"), "hello-world");
634        assert_eq!(heading_to_fragment("Test Case"), "test-case");
635        assert_eq!(heading_to_fragment(""), "");
636    }
637
638    #[test]
639    fn test_github_underscores() {
640        // GitHub preserves underscores in snake_case but removes emphasis markdown
641        assert_eq!(heading_to_fragment("test_with_underscores"), "test_with_underscores");
642        assert_eq!(heading_to_fragment("Update login_type"), "update-login_type");
643        assert_eq!(heading_to_fragment("__dunder__"), "dunder"); // Emphasis removed
644        assert_eq!(heading_to_fragment("_emphasized_"), "emphasized"); // Single underscore emphasis
645        assert_eq!(heading_to_fragment("__double__ underscore"), "double-underscore");
646    }
647
648    #[test]
649    fn test_github_arrows_issue_39() {
650        // These are the specific cases from issue #39 that were failing
651        assert_eq!(
652            heading_to_fragment("cbrown --> sbrown: --unsafe-paths"),
653            "cbrown----sbrown---unsafe-paths"
654        );
655        assert_eq!(heading_to_fragment("cbrown -> sbrown"), "cbrown---sbrown");
656        assert_eq!(
657            heading_to_fragment("Arrow Test <-> bidirectional"),
658            "arrow-test---bidirectional"
659        );
660        assert_eq!(heading_to_fragment("Double Arrow ==> Test"), "double-arrow--test");
661    }
662
663    #[test]
664    fn test_github_hyphens() {
665        // GitHub preserves consecutive hyphens (no consolidation)
666        assert_eq!(heading_to_fragment("Double--Hyphen"), "double--hyphen");
667        assert_eq!(heading_to_fragment("Triple---Dash"), "triple---dash");
668        assert_eq!(
669            heading_to_fragment("Test---with---multiple---hyphens"),
670            "test---with---multiple---hyphens"
671        );
672    }
673
674    #[test]
675    fn test_github_special_symbols() {
676        assert_eq!(heading_to_fragment("Testing & Coverage"), "testing--coverage");
677        assert_eq!(heading_to_fragment("Copyright © 2024"), "copyright--2024");
678        assert_eq!(
679            heading_to_fragment("API::Response > Error--Handling"),
680            "apiresponse--error--handling"
681        );
682    }
683
684    #[test]
685    fn test_github_unicode() {
686        // GitHub preserves Unicode letters
687        assert_eq!(heading_to_fragment("Café René"), "café-rené");
688        assert_eq!(heading_to_fragment("naïve résumé"), "naïve-résumé");
689        assert_eq!(heading_to_fragment("über uns"), "über-uns");
690    }
691
692    #[test]
693    fn test_github_emojis() {
694        // GitHub converts emojis to hyphens
695        assert_eq!(heading_to_fragment("Emoji 🎉 Party"), "emoji--party");
696        assert_eq!(heading_to_fragment("Test 🚀 Rocket"), "test--rocket");
697    }
698
699    #[test]
700    fn test_github_markdown_removal() {
701        assert_eq!(heading_to_fragment("*emphasized* text"), "emphasized-text");
702        assert_eq!(heading_to_fragment("`code` in heading"), "code-in-heading");
703        assert_eq!(heading_to_fragment("[link text](url)"), "link-text");
704        assert_eq!(heading_to_fragment("[ref link][]"), "ref-link");
705    }
706
707    #[test]
708    fn test_github_html_jsx_tag_stripping() {
709        // Issue #510: GitHub strips HTML/JSX tags from headings when generating anchors
710
711        // Self-closing JSX tag
712        assert_eq!(heading_to_fragment("retentionPolicy<Component />"), "retentionpolicy");
713
714        // JSX with attributes
715        assert_eq!(
716            heading_to_fragment("retentionPolicy<HeaderTag type=\"danger\" text=\"required\" />"),
717            "retentionpolicy"
718        );
719
720        // HTML span with content (tags stripped, inner text preserved)
721        assert_eq!(heading_to_fragment("Test <span>extra</span>"), "test-extra");
722
723        // Multiple HTML tags
724        assert_eq!(
725            heading_to_fragment("A <b>bold</b> and <i>italic</i>"),
726            "a-bold-and-italic"
727        );
728
729        // Mixed code spans and JSX (code span content preserved, JSX stripped)
730        assert_eq!(heading_to_fragment("`code`<Tag />"), "code");
731
732        // Single-letter type parameter (GitHub strips these too)
733        assert_eq!(heading_to_fragment("Generic<T>"), "generic");
734
735        // Self-closing HTML tag
736        assert_eq!(heading_to_fragment("Text<br />More"), "textmore");
737
738        // Nested HTML
739        assert_eq!(
740            heading_to_fragment("Test <div><span>nested</span></div>"),
741            "test-nested"
742        );
743
744        // Arrow patterns should NOT be affected by HTML tag stripping
745        assert_eq!(
746            heading_to_fragment("Arrow Test <-> bidirectional"),
747            "arrow-test---bidirectional"
748        );
749    }
750
751    #[test]
752    fn test_github_html_comment_stripping() {
753        // Verified against GitHub.com gist rendering: HTML comments `<!-- ... -->`
754        // are invisible in rendered output and stripped from anchors. The surrounding
755        // whitespace is preserved and becomes hyphens via the normal pipeline.
756
757        // Trailing comment — space before comment survives as a trailing hyphen
758        assert_eq!(heading_to_fragment("Hello <!-- world -->"), "hello-");
759
760        // Comment in the middle — two flanking spaces become two hyphens
761        assert_eq!(heading_to_fragment("A <!-- c --> B"), "a--b");
762
763        // Leading comment — space after comment becomes a leading hyphen
764        assert_eq!(heading_to_fragment("<!-- hidden --> Title"), "-title");
765
766        // Comment abutting text with no spaces — nothing survives the comment
767        assert_eq!(heading_to_fragment("Title<!-- no space -->"), "title");
768
769        // Multiple adjacent comments — both fully stripped
770        assert_eq!(heading_to_fragment("Hello<!-- x --><!-- y -->"), "hello");
771
772        // Empty comment (`<!-- -->`) is still a full comment, gets stripped
773        assert_eq!(heading_to_fragment("Hello <!-- --> World"), "hello--world");
774        assert_eq!(heading_to_fragment("Arrow <!-- --> Test"), "arrow--test");
775
776        // Comment content never leaks into the anchor, even when it contains
777        // text that would otherwise form arrow sequences
778        assert_eq!(heading_to_fragment("Has <!-- arrow --> inside"), "has--inside");
779
780        // Only the comment is stripped — a real `-->` outside the comment still
781        // goes through the arrow-replacement pipeline
782        assert_eq!(
783            heading_to_fragment("Has <!-- --> and --> outside"),
784            "has--and----outside"
785        );
786
787        // Unclosed comments are left as literal text (GitHub treats them as text too)
788        assert_eq!(heading_to_fragment("Unclosed <!-- comment"), "unclosed----comment");
789    }
790
791    #[test]
792    fn test_github_leading_trailing() {
793        // GitHub does NOT trim leading/trailing hyphens (unlike kramdown)
794        assert_eq!(heading_to_fragment("---leading"), "---leading");
795        assert_eq!(heading_to_fragment("trailing---"), "trailing---");
796        assert_eq!(heading_to_fragment("---both---"), "---both---");
797    }
798
799    #[test]
800    fn test_github_numbers() {
801        assert_eq!(heading_to_fragment("Step 1: Getting Started"), "step-1-getting-started");
802        assert_eq!(heading_to_fragment("Version 2.1.0"), "version-210");
803        assert_eq!(heading_to_fragment("123 Numbers"), "123-numbers");
804    }
805
806    #[test]
807    fn test_github_comprehensive_verified() {
808        // These test cases were verified against actual GitHub Gist behavior
809        let test_cases = [
810            ("GitHub Anchor Generation Test", "github-anchor-generation-test"),
811            (
812                "Test Case 1: cbrown --> sbrown: --unsafe-paths",
813                "test-case-1-cbrown----sbrown---unsafe-paths",
814            ),
815            ("Test Case 2: PHP $_REQUEST", "test-case-2-php-_request"),
816            ("Test Case 3: Update login_type", "test-case-3-update-login_type"),
817            (
818                "Test Case 4: Test with: colons > and arrows",
819                "test-case-4-test-with-colons--and-arrows",
820            ),
821            (
822                "Test Case 5: Test---with---multiple---hyphens",
823                "test-case-5-test---with---multiple---hyphens",
824            ),
825            ("Test Case 6: Simple test case", "test-case-6-simple-test-case"),
826            (
827                "Test Case 7: API::Response > Error--Handling",
828                "test-case-7-apiresponse--error--handling",
829            ),
830        ];
831
832        for (input, expected) in test_cases {
833            let actual = heading_to_fragment(input);
834            assert_eq!(
835                actual, expected,
836                "GitHub verified test failed for input: '{input}'\nExpected: '{expected}'\nActual: '{actual}'"
837            );
838        }
839    }
840
841    // Security Tests
842
843    #[test]
844    fn test_security_input_size_limits() {
845        // Test input size limits to prevent memory exhaustion
846        let large_input = "a".repeat(20000); // 20KB input
847        let result = heading_to_fragment(&large_input);
848
849        // Should be truncated to MAX_INPUT_LENGTH
850        assert!(result.len() <= MAX_INPUT_LENGTH);
851
852        // Empty input should return empty
853        assert_eq!(heading_to_fragment(""), "");
854    }
855
856    #[test]
857    fn test_security_unicode_normalization() {
858        // Test Unicode normalization prevents homograph attacks
859
860        // Different Unicode representations of "café"
861        let normal_cafe = "café"; // NFC normalized
862        let decomposed_cafe = "cafe\u{0301}"; // NFD decomposed (e + combining acute)
863
864        let result1 = heading_to_fragment(normal_cafe);
865        let result2 = heading_to_fragment(decomposed_cafe);
866
867        // Both should normalize to the same result
868        assert_eq!(result1, result2);
869        assert_eq!(result1, "café");
870    }
871
872    #[test]
873    fn test_security_bidi_injection_prevention() {
874        // Test bidirectional text injection attack prevention
875
876        // RTL override attack attempt
877        let rtl_attack = "Hello\u{202E}dlroW\u{202D}";
878        let result = heading_to_fragment(rtl_attack);
879        assert_eq!(result, "hellodlrow"); // RTL overrides should be removed
880
881        // RLO/LRO attack
882        let rlo_attack = "user\u{202E}@bank.com";
883        let result = heading_to_fragment(rlo_attack);
884        assert!(!result.contains('\u{202E}')); // Should not contain RTL override
885
886        // Isolate attacks
887        let isolate_attack = "test\u{2066}hidden\u{2069}text";
888        let result = heading_to_fragment(isolate_attack);
889        assert_eq!(result, "testhiddentext"); // Isolate chars should be removed
890    }
891
892    #[test]
893    fn test_security_zero_width_character_removal() {
894        // Test zero-width character injection prevention
895
896        let zero_width_attack = "hel\u{200B}lo\u{200C}wor\u{200D}ld\u{FEFF}";
897        let result = heading_to_fragment(zero_width_attack);
898        assert_eq!(result, "helloworld"); // All zero-width chars should be removed
899
900        // Test various zero-width characters
901        let zwj_attack = "test\u{200D}text"; // Zero Width Joiner
902        let result = heading_to_fragment(zwj_attack);
903        assert_eq!(result, "testtext");
904
905        let bom_attack = "test\u{FEFF}text"; // Byte Order Mark
906        let result = heading_to_fragment(bom_attack);
907        assert_eq!(result, "testtext");
908    }
909
910    #[test]
911    fn test_security_control_character_filtering() {
912        // Test control character filtering
913
914        let control_chars = "test\x01\x02\x03\x1F text";
915        let result = heading_to_fragment(control_chars);
916        assert_eq!(result, "test-text"); // Control chars removed, space becomes hyphen
917
918        // Preserve normal whitespace
919        let normal_whitespace = "test\n\t text";
920        let result = heading_to_fragment(normal_whitespace);
921        assert_eq!(result, "test---text"); // Multiple whitespace becomes hyphens (\n, \t, space)
922    }
923
924    #[test]
925    fn test_security_comprehensive_emoji_detection() {
926        // Test comprehensive emoji detection including country flags and keycaps
927        // Note: GitHub preserves keycap emojis but removes other emojis
928
929        // Country flags (regional indicators)
930        let flag_test = "Hello 🇺🇸 World 🇬🇧 Test";
931        let result = heading_to_fragment(flag_test);
932        assert_eq!(result, "hello--world--test"); // Flags should be removed
933
934        // Keycap sequences - GitHub PRESERVES these
935        let keycap_test = "Step 1️⃣ and 2️⃣ complete";
936        let result = heading_to_fragment(keycap_test);
937        assert_eq!(result, "step-1️⃣-and-2️⃣-complete"); // Keycaps are PRESERVED by GitHub
938
939        // Complex emoji sequences
940        let complex_emoji = "Test 👨‍👩‍👧‍👦 family";
941        let result = heading_to_fragment(complex_emoji);
942        assert_eq!(result, "test--family"); // Complex emoji should be single --
943
944        // Mixed emoji and symbols
945        let mixed_symbols = "Math ∑ ∆ 🧮 symbols";
946        let result = heading_to_fragment(mixed_symbols);
947        assert_eq!(result, "math----symbols"); // All symbols should be removed
948    }
949
950    #[test]
951    fn test_security_redos_resistance() {
952        // Test ReDoS resistance with pathological inputs
953
954        // Nested patterns that could cause exponential backtracking
955        let nested_emphasis = "*".repeat(50) + "text" + &"*".repeat(50);
956        let result = heading_to_fragment(&nested_emphasis);
957        // Should not hang and should produce reasonable output
958        assert!(result.len() < 200); // Bounded output
959
960        // Deeply nested code blocks
961        let nested_code = "`".repeat(100) + "code" + &"`".repeat(100);
962        let result = heading_to_fragment(&nested_code);
963        assert!(result.len() < 300); // Bounded output
964
965        // Pathological link patterns
966        let nested_links = "[".repeat(50) + "text" + &"]".repeat(50);
967        let result = heading_to_fragment(&nested_links);
968        assert!(result.len() < 200); // Bounded output
969    }
970
971    #[test]
972    fn test_security_dangerous_unicode_blocks() {
973        // Test filtering of dangerous Unicode blocks
974
975        // Private Use Area characters (potential malicious content)
976        let pua_test = "test\u{E000}\u{F8FF}text";
977        let result = heading_to_fragment(pua_test);
978        assert_eq!(result, "testtext"); // PUA chars should be filtered
979
980        // Variation selectors (can change appearance)
981        let variation_test = "test\u{FE00}\u{FE0F}text";
982        let result = heading_to_fragment(variation_test);
983        assert_eq!(result, "testtext"); // Variation selectors should be filtered
984    }
985
986    #[test]
987    fn test_security_normal_behavior_preserved() {
988        // Ensure security measures don't break normal functionality
989
990        // Normal Unicode letters should still work
991        let unicode_letters = "Café René naïve über";
992        let result = heading_to_fragment(unicode_letters);
993        assert_eq!(result, "café-rené-naïve-über");
994
995        // Normal ASCII should still work
996        let ascii_test = "Hello World 123";
997        let result = heading_to_fragment(ascii_test);
998        assert_eq!(result, "hello-world-123");
999
1000        // GitHub-specific behavior should be preserved
1001        let github_specific = "cbrown --> sbrown: --unsafe-paths";
1002        let result = heading_to_fragment(github_specific);
1003        assert_eq!(result, "cbrown----sbrown---unsafe-paths");
1004    }
1005
1006    #[test]
1007    fn test_github_arrow_patterns_issue_82() {
1008        // Test cases for issue #82 - verified against GitHub.com actual behavior
1009        // Pattern: arrow itself becomes N hyphens, each adjacent space adds 1 more
1010
1011        // Single arrow (->) patterns
1012        assert_eq!(heading_to_fragment("WAL->L0 Compaction"), "wal-l0-compaction");
1013        assert_eq!(heading_to_fragment("foo->bar->baz"), "foo-bar-baz");
1014        assert_eq!(heading_to_fragment("a->b"), "a-b");
1015        assert_eq!(heading_to_fragment("a ->b"), "a--b");
1016        assert_eq!(heading_to_fragment("a-> b"), "a--b");
1017        assert_eq!(heading_to_fragment("a -> b"), "a---b");
1018
1019        // Double arrow (-->) patterns
1020        assert_eq!(heading_to_fragment("a-->b"), "a--b");
1021        assert_eq!(heading_to_fragment("a -->b"), "a---b");
1022        assert_eq!(heading_to_fragment("a--> b"), "a---b");
1023        assert_eq!(heading_to_fragment("a --> b"), "a----b");
1024
1025        // Mixed patterns
1026        assert_eq!(heading_to_fragment("cbrown -> sbrown"), "cbrown---sbrown");
1027        assert_eq!(
1028            heading_to_fragment("cbrown --> sbrown: --unsafe-paths"),
1029            "cbrown----sbrown---unsafe-paths"
1030        );
1031    }
1032
1033    #[test]
1034    fn test_security_performance_edge_cases() {
1035        // Test performance with edge cases that could cause issues
1036
1037        // Long repetitive patterns
1038        let repetitive = "ab".repeat(1000);
1039        let start = std::time::Instant::now();
1040        let result = heading_to_fragment(&repetitive);
1041        let duration = start.elapsed();
1042
1043        // Should complete quickly (under 100ms for this size)
1044        assert!(duration.as_millis() < 100);
1045        assert!(!result.is_empty());
1046
1047        // Mixed ASCII and Unicode
1048        let mixed = ("a".to_string() + "ñ").repeat(500);
1049        let start = std::time::Instant::now();
1050        let result = heading_to_fragment(&mixed);
1051        let duration = start.elapsed();
1052
1053        assert!(duration.as_millis() < 100);
1054        assert!(!result.is_empty());
1055    }
1056
1057    #[test]
1058    fn test_code_span_preserves_underscores_in_slug() {
1059        // Verified against GitHub.com via Gist: code span content is preserved literally
1060        assert_eq!(heading_to_fragment("`__hello__`"), "__hello__");
1061        assert_eq!(heading_to_fragment("`__init__`"), "__init__");
1062        assert_eq!(heading_to_fragment("`_single_`"), "_single_");
1063    }
1064
1065    #[test]
1066    fn test_emphasis_underscores_removed_from_slug() {
1067        // Verified against GitHub.com via Gist: bare emphasis underscores are stripped
1068        assert_eq!(heading_to_fragment("__hello__"), "hello");
1069        assert_eq!(heading_to_fragment("_hello_"), "hello");
1070    }
1071
1072    #[test]
1073    fn test_mixed_code_and_emphasis_in_heading() {
1074        // Verified against GitHub.com via Gist: code spans preserve content,
1075        // emphasis outside code spans is stripped
1076        assert_eq!(
1077            heading_to_fragment("`__init__` method for __MyClass__"),
1078            "__init__-method-for-myclass"
1079        );
1080    }
1081
1082    #[test]
1083    fn test_multiple_code_spans_in_heading() {
1084        // Multiple code spans each preserve their underscore content independently
1085        assert_eq!(heading_to_fragment("`__a__` and `__b__`"), "__a__-and-__b__");
1086        assert_eq!(heading_to_fragment("`__init__` and `__del__`"), "__init__-and-__del__");
1087        // Three code spans
1088        assert_eq!(heading_to_fragment("`__a__` `__b__` `__c__`"), "__a__-__b__-__c__");
1089    }
1090
1091    #[test]
1092    fn test_adjacent_code_spans_in_heading() {
1093        // Adjacent code spans with no space between them
1094        assert_eq!(heading_to_fragment("`__a__``__b__`"), "__a____b__");
1095        assert_eq!(heading_to_fragment("`_x_``_y_`"), "_x__y_");
1096    }
1097
1098    #[test]
1099    fn test_double_backtick_code_span_preserves_content() {
1100        // Double-backtick code spans should also preserve their content as-is,
1101        // just like single-backtick code spans.
1102        assert_eq!(heading_to_fragment("``__init__``"), "__init__");
1103        assert_eq!(heading_to_fragment("``__hello__``"), "__hello__");
1104        assert_eq!(heading_to_fragment("``_single_``"), "_single_");
1105    }
1106
1107    #[test]
1108    fn test_double_backtick_code_span_with_surrounding_text() {
1109        // Double-backtick code span mixed with regular text and emphasis
1110        assert_eq!(
1111            heading_to_fragment("``__init__`` method for __MyClass__"),
1112            "__init__-method-for-myclass"
1113        );
1114    }
1115
1116    #[test]
1117    fn test_double_backtick_code_span_containing_single_backtick() {
1118        // A key use case for double-backtick spans: they can contain a literal backtick
1119        assert_eq!(heading_to_fragment("``code`here``"), "codehere");
1120    }
1121
1122    #[test]
1123    fn test_code_span_with_parentheses() {
1124        // Parentheses and commas inside code spans are stripped by the character filter;
1125        // spaces become hyphens
1126        assert_eq!(heading_to_fragment("`__init__(self, name)`"), "__init__self-name");
1127        assert_eq!(heading_to_fragment("`foo(bar)`"), "foobar");
1128        assert_eq!(heading_to_fragment("`func(a, b, c)`"), "funca-b-c");
1129    }
1130}