Skip to content

Commit 537672d

Browse files
committed
fix(irc): guard surrogate-range codepoints in \u literal-escape decoder
Two-step decode in decodeLiteralEscapes: - Step 1: regex anchored to high-surrogate range (U+D800–U+DBFF) so a preceding BMP escape (e.g. \u0041) cannot consume the high-surrogate half of a valid pair like \uD83D\uDE00 (😀), leaving \uDE00 lone. - Step 2: decode remaining BMP codepoints; preserve lone surrogates as six-character literals instead of corrupting them to U+FFFD in the outbound IRC UTF-8 stream.
1 parent 7c47904 commit 537672d

2 files changed

Lines changed: 70 additions & 7 deletions

File tree

extensions/irc/src/protocol.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,52 @@ describe("irc protocol", () => {
3636
expect(() => sanitizeIrcTarget(" user")).toThrow(/Invalid IRC target/);
3737
});
3838

39+
describe("\\u escape surrogate-range guard", () => {
40+
const LONE_SURROGATE = /[\uD800-\uDFFF]/;
41+
42+
it("preserves literal \\uXXXX when codepoint is a high surrogate", () => {
43+
const out = sanitizeIrcOutboundText("\\uD800");
44+
expect(LONE_SURROGATE.test(out)).toBe(false);
45+
});
46+
47+
it("preserves literal \\uXXXX when codepoint is a low surrogate", () => {
48+
const out = sanitizeIrcOutboundText("\\uDFFF");
49+
expect(LONE_SURROGATE.test(out)).toBe(false);
50+
});
51+
52+
it("still decodes valid BMP codepoints outside the surrogate range", () => {
53+
expect(sanitizeIrcOutboundText("\\u0041")).toBe("A");
54+
expect(sanitizeIrcOutboundText("\\u00e9")).toBe("é"); // é
55+
});
56+
57+
it("decodes adjacent surrogate-pair escapes to the astral character", () => {
58+
expect(sanitizeIrcOutboundText("\\uD83D\\uDE00")).toBe("😀");
59+
expect(sanitizeIrcOutboundText("\\uD83D\\uDE00\\uD83D\\uDE01")).toBe("😀😁");
60+
});
61+
62+
it("preserves lone high surrogate even when followed by a non-surrogate \\u", () => {
63+
const out = sanitizeIrcOutboundText("\\uD800\\u0041");
64+
expect(LONE_SURROGATE.test(out)).toBe(false);
65+
expect(out).toContain("A");
66+
});
67+
68+
it("decodes BMP-escaped prefix before a surrogate pair correctly", () => {
69+
// Regression: \\u0041\\uD83D\\uDE00 must yield A😀, not A\\uD83D\\uDE00.
70+
// The old step-1 regex \\u(xxxx)\\u(xxxx) would consume \\u0041\\uD83D as a
71+
// non-pair, leaving \\uDE00 as a lone surrogate.
72+
expect(sanitizeIrcOutboundText("\\u0041\\uD83D\\uDE00")).toBe("A😀");
73+
});
74+
75+
it("handles lone high surrogate followed by a different surrogate pair", () => {
76+
// \\uD800\\uD83D\\uDE00: D800 is lone (no matching low), D83D+DE00 form 😀.
77+
// Use toBe rather than LONE_SURROGATE regex: emoji contains surrogate
78+
// code units internally that would trigger a naive /[\uD800-\uDFFF]/ check.
79+
expect(sanitizeIrcOutboundText("\\uD800\\uD83D\\uDE00")).toBe("\\uD800😀");
80+
});
81+
82+
it("preserves two consecutive lone high surrogates", () => {
83+
const out = sanitizeIrcOutboundText("\\uD800\\uD801");
84+
expect(LONE_SURROGATE.test(out)).toBe(false);
85+
});
86+
});
3987
});

extensions/irc/src/protocol.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,28 @@ export function parseIrcPrefix(prefix?: string): ParsedIrcPrefix {
109109
function decodeLiteralEscapes(input: string): string {
110110
// Defensive: this is not a full JS string unescaper.
111111
// It's just enough to catch common "\r\n" / "\u0001" style payloads.
112-
return input
113-
.replace(/\\r/g, "\r")
114-
.replace(/\\n/g, "\n")
115-
.replace(/\\t/g, "\t")
116-
.replace(/\\0/g, "\0")
117-
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
118-
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
112+
return (
113+
input
114+
.replace(/\\r/g, "\r")
115+
.replace(/\\n/g, "\n")
116+
.replace(/\\t/g, "\t")
117+
.replace(/\\0/g, "\0")
118+
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
119+
// Step 1: decode surrogate pairs — first group locked to high surrogate range
120+
// (U+D800–U+DBFF: [dD][89abAB]xx), second to low surrogate range
121+
// (U+DC00–U+DFFF: [dD][c-fC-F]xx). This prevents a non-surrogate \uXXXX
122+
// from being greedily paired with the following high surrogate and consuming it.
123+
.replace(/\\u([dD][89abAB][0-9a-fA-F]{2})\\u([dD][c-fC-F][0-9a-fA-F]{2})/g, (_match, h, l) =>
124+
String.fromCodePoint(
125+
0x10000 + ((Number.parseInt(h, 16) - 0xd800) << 10) + (Number.parseInt(l, 16) - 0xdc00),
126+
),
127+
)
128+
// Step 2: decode BMP codepoints; preserve lone surrogates as literals
129+
.replace(/\\u([0-9a-fA-F]{4})/g, (match, hex) => {
130+
const codePoint = Number.parseInt(hex, 16);
131+
return codePoint >= 0xd800 && codePoint <= 0xdfff ? match : String.fromCharCode(codePoint);
132+
})
133+
);
119134
}
120135

121136
export function sanitizeIrcOutboundText(text: string): string {

0 commit comments

Comments
 (0)