OjG, a Go library for JSON, refused \/ in a quoted name and read an escaped UTF-16 pair as two U+FFFD. RFC 9535 section 2.3.1.1 allows both. I fixed it in PR #253, merged about five minutes after I opened it.

When it happens

jp.ParseString(`$["\/"]`)
// 0x2f (/) is not a valid escaped character

jp.MustParseString(`$["\ud83d\ude00"]`).Get(map[string]any{"😀": 1})
// []   want [1]

The first should parse as $['/']. A high half and a low half, each escaped, are one character, so the second should return [1]. Single-quoted names and filter strings act alike.

How a quoted string is decoded

readEscStr in jp/parse.go decodes quoted names and strings. Its switch on the character after a backslash had no / case, so \/ hit the error. Its u case turns four hex digits into one rune, so a pair gave two runes, each U+FFFD.

Why the gap existed

readEscStr was added in March 2023 with a / case. In February 2024 the regexp reader stopped calling readStr and scanned for its own closing /; the same diff removed the / case. No test in jp used \/ outside a regexp or held a surrogate escape.

The fix

The jp/parse.go diff, trimmed: no context lines and no unicode/utf16 import.

@@ -457,0 +459,2 @@ top:
+			case '/':
+				buf = append(buf, '/')
@@ -481,0 +485,11 @@ top:
+				// A high surrogate escape followed by a low surrogate escape is one rune.
+				if utf16.IsSurrogate(r) && p.pos+6 <= len(p.buf) && p.buf[p.pos] == '\\' && (p.buf[p.pos+1] == 'u' || p.buf[p.pos+1] == 'U') {
+					var r2 rune
+					for _, c := range p.buf[p.pos+2 : p.pos+6] {
+						r2 = (r2 << 4) | rune(p.readHex(c))
+					}
+					if d := utf16.DecodeRune(r, r2); d != utf8.RuneError {
+						r = d
+						p.pos += 6
+					}
+				}

When a surrogate escape is followed by a \u or \U escape and utf16.DecodeRune joins them, the one rune is appended and the second escape skipped. Otherwise the second escape is read on its own, as before.

The test

Seventeen rows: thirteen in getTestData (run by TestExprGet) and four in TestParse. Without the fix both tests fail: eleven rows fail, six pass either way. Two rows:

{path: `$["\ud83d\ude00"]`, expect: []any{3}, data: map[string]any{"😀": 3, "\ufffd\ufffd": 4}},
{path: `$["\ude00\ud83d"]`, expect: []any{3}, data: map[string]any{"\ufffd\ufffd": 3}},

The first holds a decoy key, so the old code returns 4, not nothing. The second passes either way.

What the maintainer did

@ohler55 approved it with no comment and squash-merged it. The merged files match my branch.

The lesson

  • A case in a shared function serves every caller. Before deleting one, check the others and give it a row.
  • Every other pair row has the high half first, so only the swapped row pins the order the grammar sets.

If your decoder reads UTF-16 escapes one at a time, reply on X.