Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions internal/channelregistry/name_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package channelregistry

import (
"errors"
"reflect"
"strings"
"testing"
"unicode"
)

func TestNormalizeNameAcceptsAndPreservesCase(t *testing.T) {
Expand Down Expand Up @@ -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
Expand Down
34 changes: 33 additions & 1 deletion public/channel-proposals.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.' };
Expand Down Expand Up @@ -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,
Expand Down
70 changes: 70 additions & 0 deletions test-channel-proposals.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading