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: ®ex::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}