@@ -118,19 +118,49 @@ func TestFilterInvisibleCharacters(t *testing.T) {
118118 expected : "HelloWorld" ,
119119 },
120120 {
121- name : "text with variation selector" ,
121+ name : "orphaned variation selector after ascii letter " ,
122122 input : "Hello\uFE0F World" ,
123123 expected : "HelloWorld" ,
124124 },
125125 {
126- name : "text with variation selector supplement " ,
126+ name : "ideographic variation selector after non-ideograph base " ,
127127 input : "Hello\U000E0100 World" ,
128128 expected : "HelloWorld" ,
129129 },
130130 {
131- name : "emoji variation selector hidden after emoji (steganography)" ,
132- input : "\U0001F600 \uFE0F \U000E0101 Hi" ,
133- expected : "\U0001F600 Hi" ,
131+ name : "variation selector at start of input has no base" ,
132+ input : "\uFE0F Hello" ,
133+ expected : "Hello" ,
134+ },
135+ {
136+ name : "variation selector orphaned by removed zero width space" ,
137+ input : "\u2708 \u200B \uFE0F " ,
138+ expected : "\u2708 " ,
139+ },
140+ {
141+ name : "smuggled selector run after emoji keeps only the presentation selector" ,
142+ input : "\U0001F600 \uFE0F \U000E0101 \U000E0102 Hi" ,
143+ expected : "\U0001F600 \uFE0F Hi" ,
144+ },
145+ {
146+ name : "emoji presentation sequence is preserved" ,
147+ input : "Book a flight \u2708 \uFE0F today" ,
148+ expected : "Book a flight \u2708 \uFE0F today" ,
149+ },
150+ {
151+ name : "text presentation sequence is preserved" ,
152+ input : "Book a flight \u2708 \uFE0E today" ,
153+ expected : "Book a flight \u2708 \uFE0E today" ,
154+ },
155+ {
156+ name : "keycap sequence is preserved" ,
157+ input : "Step 1\uFE0F \u20E3 first" ,
158+ expected : "Step 1\uFE0F \u20E3 first" ,
159+ },
160+ {
161+ name : "registered cjk ideographic variation sequence is preserved" ,
162+ input : "\u845B \U000E0100 \u57CE " ,
163+ expected : "\u845B \U000E0100 \u57CE " ,
134164 },
135165 }
136166
@@ -189,19 +219,13 @@ func TestShouldRemoveRune(t *testing.T) {
189219 // Additional directional mark
190220 {name : "arabic letter mark" , rune : 0x061C , expected : true },
191221
192- // Range tests - Variation selectors: U+FE00–U+FE0F
193- {name : "variation selector range start" , rune : 0xFE00 , expected : true },
194- {name : "variation selector range middle" , rune : 0xFE05 , expected : true },
195- {name : "variation selector range end (VS16, emoji presentation)" , rune : 0xFE0F , expected : true },
196- {name : "before variation selector range" , rune : 0xFDFF , expected : false },
197- {name : "after variation selector range" , rune : 0xFE10 , expected : false },
198-
199- // Range tests - Variation selectors supplement: U+E0100–U+E01EF
200- {name : "variation selector supplement range start" , rune : 0xE0100 , expected : true },
201- {name : "variation selector supplement range middle" , rune : 0xE0150 , expected : true },
202- {name : "variation selector supplement range end" , rune : 0xE01EF , expected : true },
203- {name : "before variation selector supplement range" , rune : 0xE00FF , expected : false },
204- {name : "after variation selector supplement range" , rune : 0xE01F0 , expected : false },
222+ // Variation selectors are filtered contextually by
223+ // FilterInvisibleCharacters, so shouldRemoveRune never removes them on
224+ // its own. See TestIsValidVariationSequence for that behaviour.
225+ {name : "variation selector range start" , rune : 0xFE00 , expected : false },
226+ {name : "variation selector range end (VS16, emoji presentation)" , rune : 0xFE0F , expected : false },
227+ {name : "variation selector supplement range start" , rune : 0xE0100 , expected : false },
228+ {name : "variation selector supplement range end" , rune : 0xE01EF , expected : false },
205229
206230 // Characters that should NOT be removed
207231 {name : "regular ascii letter" , rune : 'A' , expected : false },
@@ -359,7 +383,7 @@ func TestSanitizeFiltersInvisibleCharactersAfterEntityDecoding(t *testing.T) {
359383 expected : "HelloWorld" ,
360384 },
361385 {
362- name : "hexadecimal entity for zero width space (lowercase x, uppercase hex)" ,
386+ name : "hexadecimal entity for zero width space (lowercase hex digits )" ,
363387 input : "Hello​World" ,
364388 expected : "HelloWorld" ,
365389 },
@@ -374,15 +398,20 @@ func TestSanitizeFiltersInvisibleCharactersAfterEntityDecoding(t *testing.T) {
374398 expected : "HelloWorld" ,
375399 },
376400 {
377- name : "decimal entity for variation selector" ,
401+ name : "decimal entity for orphaned variation selector" ,
378402 input : "Hello️World" ,
379403 expected : "HelloWorld" ,
380404 },
381405 {
382- name : "hexadecimal entity for variation selector supplement" ,
406+ name : "hexadecimal entity for orphaned variation selector supplement" ,
383407 input : "Hello󠄀World" ,
384408 expected : "HelloWorld" ,
385409 },
410+ {
411+ name : "entity encoded selector run after emoji is truncated to one selector" ,
412+ input : "Ship it \U0001F600 ️󠄁󠄂" ,
413+ expected : "Ship it \U0001F600 \uFE0F " ,
414+ },
386415 {
387416 name : "direct invisible rune alongside entity encoded one" ,
388417 input : "Hello\u200B ‎World" ,
@@ -403,6 +432,57 @@ func TestSanitizeFiltersInvisibleCharactersAfterEntityDecoding(t *testing.T) {
403432 input : "Hello 世界 🌍 αβγ" ,
404433 expected : "Hello 世界 🌍 αβγ" ,
405434 },
435+ {
436+ name : "emoji presentation sequence survives the full pipeline" ,
437+ input : "Book a flight \u2708 \uFE0F today" ,
438+ expected : "Book a flight \u2708 \uFE0F today" ,
439+ },
440+ {
441+ name : "registered cjk ideographic variation sequence survives the full pipeline" ,
442+ input : "\u845B \U000E0100 \u57CE " ,
443+ expected : "\u845B \U000E0100 \u57CE " ,
444+ },
445+ }
446+
447+ for _ , tt := range tests {
448+ t .Run (tt .name , func (t * testing.T ) {
449+ result := Sanitize (tt .input )
450+ assert .Equal (t , tt .expected , result )
451+ })
452+ }
453+ }
454+
455+ // TestSanitizeRemovesCodeFenceMetadataRevealedByEntityDecoding covers fences
456+ // that only become fences after HTML entity decoding. A leading "`​“"
457+ // is not a fence in the raw input, so the first FilterCodeFenceMetadata pass
458+ // leaves it alone; once the entity is decoded and the zero width space is
459+ // removed the line is a real fence, so the fence filter has to run again.
460+ func TestSanitizeRemovesCodeFenceMetadataRevealedByEntityDecoding (t * testing.T ) {
461+ tests := []struct {
462+ name string
463+ input string
464+ expected string
465+ }{
466+ {
467+ name : "decimal entity hides fence delimiter" ,
468+ input : "`​``steal secrets\n fmt.Println(42)\n ```" ,
469+ expected : "```\n fmt.Println(42)\n ```" ,
470+ },
471+ {
472+ name : "hexadecimal entity hides fence delimiter" ,
473+ input : "``​`steal secrets\n fmt.Println(42)\n ```" ,
474+ expected : "```\n fmt.Println(42)\n ```" ,
475+ },
476+ {
477+ name : "entity hides fence delimiter with disallowed info string" ,
478+ input : "`​``go;rm -rf /\n code\n ```" ,
479+ expected : "```\n code\n ```" ,
480+ },
481+ {
482+ name : "entity encoded fence keeps a safe info string" ,
483+ input : "`​``go\n fmt.Println(42)\n ```" ,
484+ expected : "```go\n fmt.Println(42)\n ```" ,
485+ },
406486 }
407487
408488 for _ , tt := range tests {
@@ -412,3 +492,40 @@ func TestSanitizeFiltersInvisibleCharactersAfterEntityDecoding(t *testing.T) {
412492 })
413493 }
414494}
495+
496+ func TestIsValidVariationSequence (t * testing.T ) {
497+ tests := []struct {
498+ name string
499+ base rune
500+ selector rune
501+ expected bool
502+ }{
503+ {name : "emoji presentation selector after symbol" , base : 0x2708 , selector : 0xFE0F , expected : true },
504+ {name : "text presentation selector after symbol" , base : 0x2708 , selector : 0xFE0E , expected : true },
505+ {name : "presentation selector after emoji" , base : 0x1F600 , selector : 0xFE0F , expected : true },
506+ {name : "presentation selector after keycap digit" , base : '1' , selector : 0xFE0F , expected : true },
507+ {name : "presentation selector after keycap hash" , base : '#' , selector : 0xFE0F , expected : true },
508+ {name : "presentation selector after keycap asterisk" , base : '*' , selector : 0xFE0E , expected : true },
509+ {name : "non-presentation selector after keycap digit" , base : '1' , selector : 0xFE00 , expected : false },
510+ {name : "presentation selector after ascii letter" , base : 'a' , selector : 0xFE0F , expected : false },
511+ {name : "presentation selector after ascii punctuation" , base : '.' , selector : 0xFE0F , expected : false },
512+ {name : "standardized selector after cjk ideograph" , base : '葛' , selector : 0xFE00 , expected : true },
513+
514+ {name : "ideographic selector after cjk ideograph" , base : '葛' , selector : 0xE0100 , expected : true },
515+ {name : "ideographic selector after cjk compatibility ideograph" , base : 0xF900 , selector : 0xE0101 , expected : true },
516+ {name : "ideographic selector after emoji" , base : 0x1F600 , selector : 0xE0100 , expected : false },
517+ {name : "ideographic selector after ascii letter" , base : 'a' , selector : 0xE0100 , expected : false },
518+ {name : "ideographic selector after greek letter" , base : 'α' , selector : 0xE0100 , expected : false },
519+
520+ {name : "selector after another selector" , base : 0xFE0F , selector : 0xFE0F , expected : false },
521+ {name : "ideographic selector after another selector" , base : 0xE0100 , selector : 0xE0101 , expected : false },
522+ {name : "selector after space" , base : ' ' , selector : 0xFE0F , expected : false },
523+ {name : "selector after newline" , base : '\n' , selector : 0xFE0F , expected : false },
524+ }
525+
526+ for _ , tt := range tests {
527+ t .Run (tt .name , func (t * testing.T ) {
528+ assert .Equal (t , tt .expected , isValidVariationSequence (tt .base , tt .selector ))
529+ })
530+ }
531+ }
0 commit comments