/** * Copyright (c) 2026, Salesforce, Inc., * All rights reserved. * For full license text, see the LICENSE.txt file */ import { parse } from "graphql"; import { describe, expect, it } from "vitest"; import { CONTROL_CHAR_RE, escapeControlCharsGraphQL, LINE_SEPARATOR_RE, stripControlChars, stripLineSeparators, } from "../control-chars.js"; /** * Single source of truth for the Unicode control (Cc) + format (Cf) class shared * by the two host-visible-text sinks: the error-envelope escaper * (schemas/tool-adapter.ts neutralizeControlChars) and the enum-rejection * stripper (schemas/fields.ts enumStripControlChars, W-23336443). The exhaustive * membership matrix lives here; each consumer's spec keeps just the cases that * prove its DISPOSITION (escape vs strip). Every dangerous char is written as a * \u escape (house style, matching tool-adapter.spec.ts) so the source carries * no invisible bytes and stays reviewable; only printable Unicode that must be * PRESERVED (accents, CJK) is written as a literal. */ describe("lib/control-chars", () => { // One representative per sub-class. stripControlChars output carries no JSON // pretty-printing, so a whole-class assertion over the RESULT is exact here // (unlike an end-to-end SDK envelope, whose pretty-print newlines are // themselves Cc -- there we must assert specific injected code points). const MEMBERS: [string, string][] = [ ["C0 LF", "\n"], ["C0 CR", "\r"], ["C0 TAB", "\t"], ["C0 NUL", "\x00"], ["C0 ESC", "\x1b"], ["DEL", "\x7f"], ["C1 low (PAD)", "\x80"], ["C1 CSI", "\x9b"], ["NEL", "\u{85}"], ["soft hyphen (Cf)", "\u{ad}"], ["Arabic Letter Mark (Cf, Bidi_Control)", "\u{61c}"], ["ZWSP", "\u{200b}"], ["ZWNJ", "\u{200c}"], ["ZWJ", "\u{200d}"], ["word joiner", "\u{2060}"], ["bidi RLO", "\u{202e}"], ["bidi LRI", "\u{2066}"], ["invisible times", "\u{2062}"], ["interlinear anchor", "\u{fff9}"], ["BOM/ZWNBSP", "\u{feff}"], ["tag block (astral)", "\u{e0001}"], ]; describe("CONTROL_CHAR_RE", () => { it.each(MEMBERS)("matches %s", (_label, ch) => { // CONTROL_CHAR_RE carries the `g` flag, so `.test()` is stateful -- // reset lastIndex before probing. CONTROL_CHAR_RE.lastIndex = 0; expect(CONTROL_CHAR_RE.test(ch)).toBe(true); }); it.each([ ["ASCII letter", "A"], ["digit", "7"], ["space", " "], ["accented", "café"], ["CJK", "日本語"], ["emoji (astral)", "\u{1f600}"], ["line separator U+2028 (Zl, not Cc/Cf)", "\u{2028}"], ["paragraph separator U+2029 (Zp, not Cc/Cf)", "\u{2029}"], ])("does NOT match %s", (_label, ch) => { CONTROL_CHAR_RE.lastIndex = 0; expect(CONTROL_CHAR_RE.test(ch)).toBe(false); }); it("carries the g + u flags (required by both consumers' .replace)", () => { expect(CONTROL_CHAR_RE.flags).toContain("g"); expect(CONTROL_CHAR_RE.flags).toContain("u"); }); }); describe("stripControlChars", () => { it.each(MEMBERS)("deletes %s entirely", (_label, ch) => { expect(stripControlChars(`a${ch}b`)).toBe("ab"); }); it("deletes every occurrence, not just the first", () => { expect(stripControlChars("a\u{202e}b\u{200b}c\x7fd")).toBe("abcd"); }); it("leaves ordinary Unicode (accents, CJK, emoji) untouched", () => { const ok = "plain ASCII café 日本語 naïve \u{1f600}"; expect(stripControlChars(ok)).toBe(ok); }); it("leaves U+2028/U+2029 untouched (Zl/Zp are out of scope here)", () => { expect(stripControlChars("a\u{2028}b\u{2029}c")).toBe("a\u{2028}b\u{2029}c"); }); it("strips a value to its intended enum member (the W-23336443 case)", () => { // A bidi-poisoned-but-otherwise-valid enum value strips to the clean // member, so validation ACCEPTS it (no reflected message at all). expect(stripControlChars("describe_object\u{202e}")).toBe("describe_object"); // A genuinely-invalid poisoned value strips to an inert token -- the // rejection message it produces downstream carries no raw control char. expect(stripControlChars("\u{200b}bogus\x7f")).toBe("bogus"); }); it("output of any stripped string contains no Cc/Cf code point", () => { // Whole-class assertion is valid on the RAW stripped output (no JSON // pretty-print), so this locks round-trip completeness directly. const poison = MEMBERS.map(([, ch]) => ch).join("x"); const cleaned = stripControlChars(poison); expect(cleaned).not.toMatch(/[\p{Cc}\p{Cf}]/u); expect(cleaned).toBe("x".repeat(MEMBERS.length - 1)); }); }); // escapeControlCharsGraphQL is the THIRD disposition of the shared Cc/Cf class // (W-23336442): unlike stripControlChars (delete) or neutralizeControlChars // (\xNN / \u{...} for a plain-JSON envelope), it emits ONLY GraphQL-valid // \uXXXX escapes because its output lands inside a LIVE GraphQL string literal // -- \xNN would make the query un-parseable and \u{...} isn't universally // accepted, so astral chars become a surrogate PAIR. describe("escapeControlCharsGraphQL", () => { // Expected \uXXXX (or surrogate-pair) rendering for each representative. const ESCAPED: [string, string, string][] = [ ["C0 LF", "\n", "\\u000a"], ["C0 NUL", "\x00", "\\u0000"], ["C0 ESC", "\x1b", "\\u001b"], ["DEL (survives JSON.stringify)", "\x7f", "\\u007f"], ["C1 CSI", "\x9b", "\\u009b"], ["NEL", "\u{85}", "\\u0085"], ["ZWSP (BMP Cf)", "\u{200b}", "\\u200b"], ["bidi RLO (BMP Cf)", "\u{202e}", "\\u202e"], ["BOM/ZWNBSP", "\u{feff}", "\\ufeff"], // Astral tag char -> UTF-16 surrogate pair (U+E0001 = D800+... / DC00+...). ["tag block U+E0001 (astral)", "\u{e0001}", "\\udb40\\udc01"], ]; it.each(ESCAPED)("escapes %s to a lower-case \\uXXXX escape", (_label, ch, esc) => { expect(escapeControlCharsGraphQL(`a${ch}b`)).toBe(`a${esc}b`); }); it("emits the astral tag char as a surrogate pair, never \\u{...}", () => { const out = escapeControlCharsGraphQL("\u{e0001}"); expect(out).toBe("\\udb40\\udc01"); expect(out).not.toContain("\\u{"); }); it("leaves ordinary Unicode (accents, CJK, emoji) untouched", () => { const ok = "plain ASCII café 日本語 naïve \u{1f600}"; expect(escapeControlCharsGraphQL(ok)).toBe(ok); }); it("leaves U+2028/U+2029 untouched (Zl/Zp are out of the Cc/Cf class)", () => { expect(escapeControlCharsGraphQL("a\u{2028}b\u{2029}c")).toBe("a\u{2028}b\u{2029}c"); }); it("escapes every occurrence, not just the first", () => { expect(escapeControlCharsGraphQL("a\u{202e}b\u{200b}c\x7fd")).toBe( "a\\u202eb\\u200bc\\u007fd", ); }); it("output contains no raw Cc/Cf code point and no GraphQL-invalid \\xNN / \\u{...}", () => { const poison = ESCAPED.map(([, ch]) => ch).join("x"); const out = escapeControlCharsGraphQL(poison); expect(out).not.toMatch(/[\p{Cc}\p{Cf}]/u); // GraphQL rejects \xNN entirely and not every parser accepts \u{...}. expect(out).not.toContain("\\x"); expect(out).not.toContain("\\u{"); }); it("produces a GraphQL-parseable string literal for a poisoned value (round-trip)", () => { // Wrap the escaped output in quotes: it must be a valid GraphQL document. const escaped = escapeControlCharsGraphQL("before\u{202e}\u{200b}\x7f\u{e0001}after"); const query = `query { field(arg: "${escaped}") }`; expect(() => parse(query)).not.toThrow(); // The specific dangerous code points are absent from the raw query source. expect(query).not.toMatch(/[\u{202e}\u{200b}\u{7f}\u{e0001}]/u); }); }); // U+2028/U+2029 are Zl/Zp -- NOT part of CONTROL_CHAR_RE (asserted above) -- // so they get their own primitive. Every host-visible sink strips them // separately because raw, they trip a Claude.AI 408 (MCP TS SDK #2155): the // envelope in tool-adapter.ts and, upstream of it, the enum-rejection path // (fields.ts enumStripControlChars, W-23336443). describe("LINE_SEPARATOR_RE / stripLineSeparators", () => { it.each([ ["line separator U+2028 (Zl)", "\u{2028}"], ["paragraph separator U+2029 (Zp)", "\u{2029}"], ])("matches + deletes %s", (_label, ch) => { LINE_SEPARATOR_RE.lastIndex = 0; expect(LINE_SEPARATOR_RE.test(ch)).toBe(true); expect(stripLineSeparators(`a${ch}b`)).toBe("ab"); }); it.each([ ["ASCII newline (a Cc, handled by the other primitive)", "\n"], ["ordinary space", " "], ["accented", "café"], ["bidi RLO (a Cf, handled by the other primitive)", "\u{202e}"], ])("does NOT match %s (out of the Zl/Zp scope)", (_label, ch) => { LINE_SEPARATOR_RE.lastIndex = 0; expect(LINE_SEPARATOR_RE.test(ch)).toBe(false); }); it("deletes every occurrence, leaving ordinary text intact", () => { expect(stripLineSeparators("a\u{2028}b\u{2029}c\u{2028}d")).toBe("abcd"); const ok = "plain café 日本語"; expect(stripLineSeparators(ok)).toBe(ok); }); it("carries the g flag (required by .replace to hit every occurrence)", () => { expect(LINE_SEPARATOR_RE.flags).toContain("g"); }); }); });