@@ -454,9 +454,12 @@ $self: something`
454454 assert .Equal (t , "something" , r .Self )
455455}
456456
457- // TestUnescapeJSONSlashes tests the unescapeJSONSlashes helper function
458- // This addresses issue #479 where JSON files with \/ escape sequences fail to parse
459- func TestUnescapeJSONSlashes (t * testing.T ) {
457+ // TestNormalizeJSONForYAMLParser tests JSON escapes that are valid JSON but
458+ // rejected by the YAML parser used for YAML-node construction.
459+ func TestNormalizeJSONForYAMLParser (t * testing.T ) {
460+ thumbsUp := string (rune (0x1F44D ))
461+ rocket := string (rune (0x1F680 ))
462+
460463 tests := []struct {
461464 name string
462465 input string
@@ -475,22 +478,61 @@ func TestUnescapeJSONSlashes(t *testing.T) {
475478 {"other escapes preserved" , `\n\t\/` , `\n\t/` },
476479 {"multiple escaped slashes" , `\/one\/two\/three` , `/one/two/three` },
477480 {"mixed content" , `{"path":"\/test","url":"https:\/\/example.com"}` , `{"path":"/test","url":"https://example.com"}` },
481+ {"valid surrogate pair" , `\ud83d\udc4d` , thumbsUp },
482+ {"valid uppercase surrogate pair" , `\uD83D\uDC4D` , thumbsUp },
483+ {"multiple surrogate pairs" , `\ud83d\udc4d \ud83d\ude80` , thumbsUp + " " + rocket },
484+ {"surrogate pair with escaped slash" , `https:\/\/example.com\/\ud83d\udc4d` , `https://example.com/` + thumbsUp },
485+ {"double escaped surrogate pair" , `\\ud83d\\udc4d` , `\\ud83d\\udc4d` },
486+ {"trailing backslash" , `\` , `\` },
487+ {"lone high surrogate" , `\ud83d` , `\ud83d` },
488+ {"high surrogate without low escape" , `\ud83dxxxxxx` , `\ud83dxxxxxx` },
489+ {"high surrogate followed by non-low surrogate" , `\ud83d\u0041` , `\ud83d\u0041` },
490+ {"lone low surrogate" , `\udc4d` , `\udc4d` },
491+ {"invalid high surrogate hex" , `\ud83x\udc4d` , `\ud83x\udc4d` },
492+ {"invalid low surrogate hex" , `\ud83d\udc4x` , `\ud83d\udc4x` },
493+ {"truncated unicode escape" , `\u12` , `\u12` },
494+ {"non surrogate unicode escape" , `\u003c` , `\u003c` },
478495 }
479496
480497 for _ , tt := range tests {
481498 t .Run (tt .name , func (t * testing.T ) {
482- result := unescapeJSONSlashes ([]byte (tt .input ))
499+ result := normalizeJSONForYAMLParser ([]byte (tt .input ))
483500 assert .Equal (t , tt .expected , string (result ))
484501 })
485502 }
486503}
487504
488- // TestUnescapeJSONSlashes_NoAllocation tests that the fast path returns original slice
489- func TestUnescapeJSONSlashes_NoAllocation (t * testing.T ) {
490- input := []byte (`{"path":"/test"}` )
491- result := unescapeJSONSlashes (input )
492- // Should return same slice when no \/ present
493- assert .Equal (t , & input [0 ], & result [0 ], "Should return original slice when no \\ / present" )
505+ func TestDecodeJSONUnicodeEscape (t * testing.T ) {
506+ value , ok := decodeJSONUnicodeEscape ([]byte ("D83D" ))
507+ assert .True (t , ok )
508+ assert .Equal (t , uint16 (0xD83D ), value )
509+
510+ _ , ok = decodeJSONUnicodeEscape ([]byte ("123" ))
511+ assert .False (t , ok )
512+
513+ _ , ok = decodeJSONUnicodeEscape ([]byte ("12xz" ))
514+ assert .False (t , ok )
515+ }
516+
517+ // TestNormalizeJSONForYAMLParser_NoAllocation tests that unchanged inputs
518+ // return the original slice.
519+ func TestNormalizeJSONForYAMLParser_NoAllocation (t * testing.T ) {
520+ tests := []string {
521+ `{"path":"/test"}` ,
522+ `{"text":"line\nquoted\"tab\tunicode\u003c"}` ,
523+ `{"text":"\\ud83d\\udc4d"}` ,
524+ `{"text":"\ud83d"}` ,
525+ `{"text":"\udc4d"}` ,
526+ }
527+
528+ for _ , tt := range tests {
529+ t .Run (tt , func (t * testing.T ) {
530+ input := []byte (tt )
531+ result := normalizeJSONForYAMLParser (input )
532+ assert .Equal (t , tt , string (result ))
533+ assert .Equal (t , & input [0 ], & result [0 ], "Should return original slice when no rewrite is needed" )
534+ })
535+ }
494536}
495537
496538// TestExtractSpecInfo_JSON_EscapedSlashes tests issue #479
@@ -506,6 +548,49 @@ func TestExtractSpecInfo_JSON_EscapedSlashes(t *testing.T) {
506548 assert .Equal (t , utils .OpenApi3 , r .SpecType )
507549}
508550
551+ func TestExtractSpecInfo_JSON_SurrogatePairInExample (t * testing.T ) {
552+ jsonWithSurrogatePair := `{
553+ "openapi": "3.0.1",
554+ "info": {"title": "r", "version": "1"},
555+ "paths": {
556+ "/t": {
557+ "post": {
558+ "operationId": "t",
559+ "responses": {
560+ "201": {
561+ "description": "ok",
562+ "content": {
563+ "application/json": {
564+ "schema": {"type": "object", "properties": {"x": {"type": "string"}}},
565+ "examples": {
566+ "e": {"value": {"x": "Hello \ud83d\udc4d"}}
567+ }
568+ }
569+ }
570+ }
571+ }
572+ }
573+ }
574+ }
575+ }`
576+
577+ r , e := ExtractSpecInfo ([]byte (jsonWithSurrogatePair ))
578+ assert .NoError (t , e )
579+ assert .Equal (t , "3.0.1" , r .Version )
580+ assert .Equal (t , JSONFileType , r .SpecFileType )
581+ assert .Equal (t , utils .OpenApi3 , r .SpecType )
582+ }
583+
584+ func TestExtractSpecInfo_JSON_SurrogatePairInDescription (t * testing.T ) {
585+ jsonWithSurrogatePair := `{"openapi":"3.0.1","info":{"title":"r","version":"1","description":"Hello \ud83d\udc4d"},"paths":{}}`
586+
587+ r , e := ExtractSpecInfo ([]byte (jsonWithSurrogatePair ))
588+ assert .NoError (t , e )
589+ assert .Equal (t , "3.0.1" , r .Version )
590+ assert .Equal (t , JSONFileType , r .SpecFileType )
591+ assert .Equal (t , utils .OpenApi3 , r .SpecType )
592+ }
593+
509594// TestExtractSpecInfo_JSON_EscapedSlashes_URL tests URL paths with escaped slashes
510595func TestExtractSpecInfo_JSON_EscapedSlashes_URL (t * testing.T ) {
511596 jsonWithURL := `{"openapi":"3.0.0","info":{"title":"Test","version":"1.0.0"},"servers":[{"url":"https:\/\/api.example.com\/v1"}],"paths":{}}`
@@ -619,6 +704,48 @@ func TestSpecInfo_Release(t *testing.T) {
619704 assert .Equal (t , "3.1.0" , s .Version )
620705}
621706
707+ var normalizeJSONForYAMLParserSink []byte
708+
709+ func BenchmarkNormalizeJSONForYAMLParser_NoEscapes (b * testing.B ) {
710+ input := []byte (`{"openapi":"3.0.1","info":{"title":"r","version":"1"},"paths":{}}` )
711+ b .ReportAllocs ()
712+ b .SetBytes (int64 (len (input )))
713+
714+ for i := 0 ; i < b .N ; i ++ {
715+ normalizeJSONForYAMLParserSink = normalizeJSONForYAMLParser (input )
716+ }
717+ }
718+
719+ func BenchmarkNormalizeJSONForYAMLParser_CommonEscapesNoRewrite (b * testing.B ) {
720+ input := []byte (`{"openapi":"3.0.1","info":{"title":"line\nquoted\"tab\tunicode\u003c","version":"1"},"paths":{}}` )
721+ b .ReportAllocs ()
722+ b .SetBytes (int64 (len (input )))
723+
724+ for i := 0 ; i < b .N ; i ++ {
725+ normalizeJSONForYAMLParserSink = normalizeJSONForYAMLParser (input )
726+ }
727+ }
728+
729+ func BenchmarkNormalizeJSONForYAMLParser_EscapedSlashes (b * testing.B ) {
730+ input := []byte (`{"openapi":"3.0.1","info":{"title":"r","version":"1"},"servers":[{"url":"https:\/\/api.example.com\/v1"}],"paths":{"\/test":{}}}` )
731+ b .ReportAllocs ()
732+ b .SetBytes (int64 (len (input )))
733+
734+ for i := 0 ; i < b .N ; i ++ {
735+ normalizeJSONForYAMLParserSink = normalizeJSONForYAMLParser (input )
736+ }
737+ }
738+
739+ func BenchmarkNormalizeJSONForYAMLParser_SurrogatePair (b * testing.B ) {
740+ input := []byte (`{"openapi":"3.0.1","info":{"title":"r","version":"1","description":"Hello \ud83d\udc4d"},"paths":{}}` )
741+ b .ReportAllocs ()
742+ b .SetBytes (int64 (len (input )))
743+
744+ for i := 0 ; i < b .N ; i ++ {
745+ normalizeJSONForYAMLParserSink = normalizeJSONForYAMLParser (input )
746+ }
747+ }
748+
622749func TestSpecInfo_Release_Nil (t * testing.T ) {
623750 var s * SpecInfo
624751 s .Release () // must not panic
0 commit comments