Skip to content

Commit 3da2b14

Browse files
committed
fix(gateway): keep close reasons valid UTF-8
1 parent d96db36 commit 3da2b14

2 files changed

Lines changed: 67 additions & 1 deletion

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Buffer } from "node:buffer";
2+
import { describe, expect, test } from "vitest";
3+
import { truncateCloseReason } from "./close-reason.js";
4+
5+
describe("truncateCloseReason", () => {
6+
const multibyte = "\u{1f600}";
7+
8+
test("returns the fallback for an empty reason", () => {
9+
expect(truncateCloseReason("")).toBe("invalid handshake");
10+
});
11+
12+
test("keeps close reasons that are already within the byte limit", () => {
13+
expect(truncateCloseReason("invalid connect params")).toBe("invalid connect params");
14+
});
15+
16+
test("keeps a complete multibyte character when it fits at the byte limit", () => {
17+
const reason = `${"x".repeat(116)}${multibyte} trailing text`;
18+
const truncated = truncateCloseReason(reason);
19+
20+
expect(truncated).toBe(`${"x".repeat(116)}${multibyte}`);
21+
expect(Buffer.byteLength(truncated)).toBe(120);
22+
});
23+
24+
test("backs up before a split multibyte character", () => {
25+
const reason = `${"x".repeat(118)}${multibyte} trailing text`;
26+
const truncated = truncateCloseReason(reason);
27+
28+
expect(truncated).toBe("x".repeat(118));
29+
expect(Buffer.byteLength(truncated)).toBeLessThanOrEqual(120);
30+
expect(truncated).not.toContain("\uFFFD");
31+
});
32+
33+
test("returns an empty reason when the byte limit cannot fit one character", () => {
34+
expect(truncateCloseReason(multibyte, 3)).toBe("");
35+
});
36+
});

src/gateway/server/close-reason.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,35 @@ export function truncateCloseReason(reason: string, maxBytes = CLOSE_REASON_MAX_
1515
if (buf.length <= maxBytes) {
1616
return reason;
1717
}
18-
return buf.subarray(0, maxBytes).toString();
18+
const end = utf8BoundaryAtOrBefore(buf, maxBytes);
19+
return buf.subarray(0, end).toString();
20+
}
21+
22+
function utf8BoundaryAtOrBefore(buf: Buffer, maxBytes: number): number {
23+
const limit = Math.max(0, Math.min(Math.floor(maxBytes), buf.length));
24+
let end = 0;
25+
26+
while (end < limit) {
27+
const byte = buf[end];
28+
const charBytes = utf8SequenceLength(byte);
29+
if (end + charBytes > limit) {
30+
break;
31+
}
32+
end += charBytes;
33+
}
34+
35+
return end;
36+
}
37+
38+
function utf8SequenceLength(byte: number): number {
39+
if (byte < 0x80) {
40+
return 1;
41+
}
42+
if (byte < 0xe0) {
43+
return 2;
44+
}
45+
if (byte < 0xf0) {
46+
return 3;
47+
}
48+
return 4;
1949
}

0 commit comments

Comments
 (0)