Commit7253d6dfRecorded20 Feb 2026Repositorysigil-json

fix: Handle surrogate pairs in JSON unicode escapes

Message

The JSON decoder now correctly handles UTF-16 surrogate pairs (uD83DuDE00 → U+1F600) for emoji and supplementary plane characters. Bare low surrogates are rejected per RFC 8259.

Changed
 src/sigil/json.sgl | 33 ++++++++++++++++++++++++++-------
 test/test-json.sgl | 14 +++++++++++++-
 2 files changed, 39 insertions(+), 8 deletions(-)
Diff
src/sigil/json.sglmodified
@@ -461,19 +461,38 @@
461
(else
462
(loop (cons c chars)))))))
463
464
;; Read a \uXXXX unicode escape
465
(define (json-read-unicode-escape port)
+464
;; Read 4 hex digits from port and return as integer, or #f on error.
+465
(define (read-hex4 port)
466
(let* ((h1 (read-char port))
467
(h2 (read-char port))
468
(h3 (read-char port))
469
(h4 (read-char port)))
470
(if (or (eof-object? h1) (eof-object? h2)
471
(eof-object? h3) (eof-object? h4))
472
(error "Invalid unicode escape")
473
(let ((n (string->number (list->string (list h1 h2 h3 h4)) 16)))
474
(if n
475
(integer->char n)
476
(error "Invalid unicode escape"))))))
+472
#f
+473
(string->number (list->string (list h1 h2 h3 h4)) 16))))
+474
+475
;; Read a \uXXXX unicode escape, handling surrogate pairs.
+476
(define (json-read-unicode-escape port)
+477
(let ((n (read-hex4 port)))
+478
(cond
+479
((not n) (error "Invalid unicode escape"))
+480
;; High surrogate: must be followed by \uXXXX low surrogate
+481
((and (>= n #xD800) (<= n #xDBFF))
+482
(let ((bs (read-char port))
+483
(u (read-char port)))
+484
(if (and (eqv? bs #\\) (eqv? u #\u))
+485
(let ((low (read-hex4 port)))
+486
(if (and low (>= low #xDC00) (<= low #xDFFF))
+487
(integer->char (+ #x10000
+488
(* (- n #xD800) #x400)
+489
(- low #xDC00)))
+490
(error "Invalid surrogate pair")))
+491
(error "Expected low surrogate after high surrogate"))))
+492
;; Bare low surrogate is invalid
+493
((and (>= n #xDC00) (<= n #xDFFF))
+494
(error "Unexpected low surrogate"))
+495
(else (integer->char n)))))
496
497
;; Read a JSON number
498
(define (json-read-number port)
test/test-json.sglmodified
@@ -109,7 +109,19 @@
109
(assert-equal "a\tb" (json-decode "\"a\\tb\"")))
110
111
(test "decode unicode escape"
112
(assert-equal "A" (json-decode "\"\\u0041\""))))
+112
(assert-equal "A" (json-decode "\"\\u0041\"")))
+113
+114
(test "decode surrogate pair (emoji)"
+115
(assert-equal "\x1F600;" (json-decode "\"\\uD83D\\uDE00\"")))
+116
+117
(test "decode surrogate pair (musical symbol)"
+118
(assert-equal "\x1D11E;" (json-decode "\"\\uD834\\uDD1E\"")))
+119
+120
(test "reject bare low surrogate"
+121
(assert-true
+122
(guard (exn (else #t))
+123
(json-decode "\"\\uDE00\"")
+124
#f))))
125
126
(test-group "json-null?"
127
(test "null symbol is null"