diff --git a/internal/channelregistry/name_test.go b/internal/channelregistry/name_test.go index efb29142b..143bdfbdf 100644 --- a/internal/channelregistry/name_test.go +++ b/internal/channelregistry/name_test.go @@ -2,8 +2,10 @@ package channelregistry import ( "errors" + "reflect" "strings" "testing" + "unicode" ) func TestNormalizeNameAcceptsAndPreservesCase(t *testing.T) { @@ -133,6 +135,62 @@ func TestNormalizeNameTrimsTrailingLineSeparator(t *testing.T) { } } +// TestGoSpaceCodepointsParity pins the exact set of runes unicode.IsSpace +// (and therefore strings.TrimSpace, which NormalizeName uses) treats as +// whitespace. public/channel-proposals.js keeps its own copy of this same +// list, GO_SPACE_CODEPOINTS, to trim leading/trailing whitespace the same +// way the server does (#99 follow-up: the frontend previously used JS's +// native String.prototype.trim(), which disagrees with TrimSpace on U+FEFF +// and U+0085). This test only catches a change in Go's own Unicode +// White_Space table against the want slice below; it cannot see the JS +// list, so JS-side drift is caught only by the matching test in +// test-channel-proposals.js. The two lists are kept in sync by hand — +// update both together. If the Go stdlib table changes, regenerate the want +// slice (and the JS copy) with: +// +// for r := rune(0); r <= 0x10FFFF; r++ { if unicode.IsSpace(r) { fmt.Printf("0x%04X, ", r) } } +func TestGoSpaceCodepointsParity(t *testing.T) { + want := []rune{ + 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x0020, 0x0085, 0x00A0, + 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, + 0x2007, 0x2008, 0x2009, 0x200A, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000, + } + var got []rune + for r := rune(0); r <= 0x10FFFF; r++ { + if unicode.IsSpace(r) { + got = append(got, r) + } + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("unicode.IsSpace enumeration changed:\n got = %#v\n want = %#v\nupdate this test AND GO_SPACE_CODEPOINTS in public/channel-proposals.js together", got, want) + } +} + +func TestNormalizeNameTrimsU0085LikeGoTrimSpace(t *testing.T) { + // U+0085 (NEL) is unicode.IsSpace but not JS's native trim() set; make + // sure the Go side (the authority) does trim and accept it, matching the + // new JS behavior added for parity. + for _, in := range []string{"test\u0085", "\u0085test", "\u0085test\u0085"} { + got, err := NormalizeName(in) + if err != nil || got != "#test" { + t.Errorf("NormalizeName(%q) = %q, %v; want \"#test\", nil", in, got, err) + } + } +} + +func TestNormalizeNameRejectsLeadingTrailingFEFF(t *testing.T) { + // U+FEFF (BOM/ZWNBSP) is JS's native trim() set but not unicode.IsSpace; + // TrimSpace leaves it in place, so it hits the invisible-format check. + // Built with string(rune(0xFEFF)) rather than a source-code escape so the + // byte order mark can't end up as literal source bytes. + bom := string(rune(0xFEFF)) + for _, in := range []string{bom + "test", "test" + bom, bom + bom + "test" + bom} { + if _, err := NormalizeName(in); !errors.Is(err, ErrNameInvisible) { + t.Errorf("NormalizeName(%q) error = %v; want ErrNameInvisible", in, err) + } + } +} + func TestNormalizeNameAllowsEmojiJoinersAndVariationSelectors(t *testing.T) { for _, in := range []string{ "#\U0001f3f3\ufe0f\u200d\U0001f308", // rainbow flag: VS16 + ZWJ diff --git a/public/channel-proposals.js b/public/channel-proposals.js index 79c7c4e31..5f49499e1 100644 --- a/public/channel-proposals.js +++ b/public/channel-proposals.js @@ -40,8 +40,38 @@ return encodeURIComponent(s).replace(/%[0-9A-F]{2}/gi, 'x').length; } + // The exact set of runes Go's unicode.IsSpace treats as whitespace (and + // therefore what strings.TrimSpace trims), enumerated from the Go standard + // library rather than guessed — not the same set as JS's native + // String.prototype.trim(), which also strips U+FEFF (BOM) and does not + // strip U+0085 (NEL). internal/channelregistry/name_test.go + // (TestGoSpaceCodepointsParity) independently re-enumerates the same rune + // range with unicode.IsSpace against the same fixed list. The Go test + // catches a change in Go's own Unicode White_Space table; this JS list + // only catches drift from its own JS-side test copy. The two lists are + // kept in sync by hand — update both together. Regenerate with: + // for r := rune(0); r <= 0x10FFFF; r++ { if unicode.IsSpace(r) { ... } } + var GO_SPACE_CODEPOINTS = [ + 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x0020, 0x0085, 0x00A0, + 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, + 0x2007, 0x2008, 0x2009, 0x200A, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000 + ]; + var GO_SPACE_RE = (function () { + var chars = GO_SPACE_CODEPOINTS.map(function (cp) { + return '\\u' + ('000' + cp.toString(16).toUpperCase()).slice(-4); + }).join(''); + return new RegExp('^[' + chars + ']+|[' + chars + ']+$', 'g'); + })(); + + // Mirrors Go's strings.TrimSpace exactly (trims only GO_SPACE_CODEPOINTS + // from both ends), unlike JS's native String.prototype.trim() — see + // GO_SPACE_CODEPOINTS above for why the sets differ. + function trimGoSpace(s) { + return s.replace(GO_SPACE_RE, ''); + } + function normalizeName(raw) { - var s = String(raw == null ? '' : raw).trim(); + var s = trimGoSpace(String(raw == null ? '' : raw)); if (s.charAt(0) !== '#') s = '#' + s; var body = s.slice(1); if (!body) return { error: 'Enter a channel name.' }; @@ -662,6 +692,8 @@ MAX_NAME_BYTES: MAX_NAME_BYTES, MAX_POLL_ATTEMPTS: MAX_POLL_ATTEMPTS, FILTERS: FILTERS, + GO_SPACE_CODEPOINTS: GO_SPACE_CODEPOINTS, + trimGoSpace: trimGoSpace, normalizeName: normalizeName, mergeApprovedChannels: mergeApprovedChannels, pollDelay: pollDelay, diff --git a/test-channel-proposals.js b/test-channel-proposals.js index 25c91d2ad..8cc602623 100644 --- a/test-channel-proposals.js +++ b/test-channel-proposals.js @@ -619,6 +619,76 @@ test('normalizeName keeps emoji with ZWJ and variation selectors', () => { } }); +// ── Trim parity with internal/channelregistry.NormalizeName (#99 follow-up) ── +// JS's native String.prototype.trim() and Go's strings.TrimSpace do not agree +// on which runes are whitespace: trim() also strips U+FEFF (BOM), and does +// not strip U+0085 (NEL) — TrimSpace is the other way around. normalizeName +// must trim exactly what the server trims, so the two sides reject/accept +// the same inputs. The pinned list below matches the one independently +// enumerated in internal/channelregistry/name_test.go +// (TestGoSpaceCodepointsParity) via unicode.IsSpace over the full rune +// range. The Go test catches a change in Go's own Unicode White_Space +// table; this JS constant only catches a change to this list itself +// (assert.deepStrictEqual below just checks it still matches the runtime +// GO_SPACE_CODEPOINTS shipped in channel-proposals.js). The two lists are +// kept in sync by hand — update both together. +const GO_SPACE_CODEPOINTS = [ + 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x0020, 0x0085, 0x00A0, + 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, + 0x2007, 0x2008, 0x2009, 0x200A, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000 +]; + +test('GO_SPACE_CODEPOINTS matches the pinned Go unicode.IsSpace enumeration', () => { + // CP.GO_SPACE_CODEPOINTS is an Array from the vm context's own realm, so + // compare values (JSON round-trip), not object/constructor identity. + assert.deepStrictEqual(JSON.parse(JSON.stringify(CP.GO_SPACE_CODEPOINTS)), GO_SPACE_CODEPOINTS); +}); + +test('normalizeName rejects a leading/trailing U+FEFF as invisible instead of silently trimming it', () => { + // Go's TrimSpace does NOT strip U+FEFF, so the server sees it, decides it + // is Cf, and rejects with ErrNameInvisible. The frontend must match: not + // trim it away and submit a name the user never typed. + for (const input of ['\uFEFFtest', 'test\uFEFF', '\uFEFF\uFEFFtest\uFEFF']) { + const r = CP.normalizeName(input); + assert.ok(r.error && !r.name, `${JSON.stringify(input)} should be rejected, got ${JSON.stringify(r)}`); + assert.match(r.error, /invisible/, JSON.stringify(input)); + } +}); + +test('normalizeName trims a leading/trailing U+0085 (NEL) and accepts the result', () => { + // Go's TrimSpace DOES strip U+0085 (unicode.IsSpace), so the server sees + // "#test" and accepts it. JS's native trim() does not touch U+0085, which + // used to leave it in CONTROL_RE range and get the frontend to reject a + // name the server would have accepted. + for (const input of ['test\u0085', '\u0085test', '\u0085test\u0085']) { + const r = CP.normalizeName(input); + assert.strictEqual(r.error, undefined, `${JSON.stringify(input)}: ${r.error}`); + assert.strictEqual(r.name, '#test', JSON.stringify(input)); + } +}); + +test('normalizeName trims every codepoint Go treats as whitespace, and only those', () => { + for (const cp of GO_SPACE_CODEPOINTS) { + const c = String.fromCodePoint(cp); + const input = c + 'test' + c; + const r = CP.normalizeName(input); + assert.strictEqual(r.error, undefined, `U+${cp.toString(16).toUpperCase()}: ${r.error}`); + assert.strictEqual(r.name, '#test', `U+${cp.toString(16).toUpperCase()}`); + } + // trimGoSpace itself: no more, no less than the pinned list. + assert.strictEqual(CP.trimGoSpace(' \t\u3000hello\u3000\t '), 'hello'); + assert.strictEqual(CP.trimGoSpace('\uFEFFhello\uFEFF'), '\uFEFFhello\uFEFF', 'U+FEFF must not be trimmed'); + assert.strictEqual(CP.trimGoSpace('\u0085hello\u0085'), 'hello', 'U+0085 must be trimmed'); +}); + +test('normalizeName still rejects U+FEFF / U+200B in the middle of a name (unchanged behavior)', () => { + for (const input of ['#mid\uFEFFdle', '#mid\u200Bdle']) { + const r = CP.normalizeName(input); + assert.ok(r.error && !r.name, `${JSON.stringify(input)} should be rejected, got ${JSON.stringify(r)}`); + assert.match(r.error, /invisible/, JSON.stringify(input)); + } +}); + // ── Remove confirmation escaping (mutant M21) ──────────────────────────── // The dialog is built with the DOM API (no HTML strings with data), so the // name must arrive as text in the title and as a plain attribute value on