Skip to content

Commit 2fdd953

Browse files
authored
fix(inputquality): decode quote/apostrophe/numeric entities in thin-issue count (BEFLOW-30) (#21)
visibleBodyLength measures human-visible text for the thin-issue gate but decoded only &/</>/ , so ", ', ', and numeric entities each counted as 5-7 chars instead of 1 — inflating the count and letting a genuinely-thin, quote-heavy body escape the gate. Add "/'/' to the named-entity pass and a numeric pass for decimal (&#NN;) and hex (&#xNN;) entities, guarding NaN and codepoints above U+10FFFF (left as-is on invalid input). The count now reflects visible length.
1 parent 118bf2f commit 2fdd953

2 files changed

Lines changed: 46 additions & 1 deletion

File tree

src/core/inputquality.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ export const THIN_ISSUE_MESSAGE =
55

66
const ENTITIES: Record<string, string> = {
77
"&amp;": "&",
8+
"&apos;": "'",
89
"&gt;": ">",
910
"&lt;": "<",
1011
"&nbsp;": " ",
12+
"&quot;": '"',
13+
"&#39;": "'",
1114
};
1215

1316
/**
@@ -17,7 +20,17 @@ const ENTITIES: Record<string, string> = {
1720
*/
1821
export function visibleBodyLength(body: string): number {
1922
const stripped = body.replace(/<[^>]*>/g, "");
20-
const decoded = stripped.replace(/&nbsp;|&amp;|&lt;|&gt;/g, (m) => ENTITIES[m] ?? m);
23+
const namedDecoded = stripped.replace(/&(?:amp|apos|gt|lt|nbsp|quot|#39);/g, (m) => ENTITIES[m] ?? m);
24+
const decoded = namedDecoded.replace(
25+
/&#(?:x([0-9a-fA-F]+)|(\d+));/g,
26+
(_m: string, hex: string | undefined, dec: string | undefined) => {
27+
const codepoint = hex !== undefined ? parseInt(hex, 16) : parseInt(dec ?? "", 10);
28+
if (isNaN(codepoint) || codepoint > 0x10ffff) {
29+
return _m;
30+
}
31+
return String.fromCodePoint(codepoint);
32+
},
33+
);
2134
return decoded.replace(/\s+/g, " ").trim().length;
2235
}
2336

test/core-inputquality.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,38 @@ describe("visibleBodyLength", () => {
4242
expect(visibleBodyLength("&amp;&lt;&gt;")).toBe(3);
4343
});
4444

45+
it("decodes &quot;, &#39;, and &apos; as single chars", () => {
46+
// 10 &quot; entities = 10 visible chars, not 60
47+
expect(visibleBodyLength("&quot;&quot;&quot;&quot;&quot;&quot;&quot;&quot;&quot;&quot;")).toBe(10);
48+
expect(visibleBodyLength("it&#39;s")).toBe(4);
49+
expect(visibleBodyLength("it&apos;s")).toBe(4);
50+
});
51+
52+
it("decodes decimal numeric entities as single chars", () => {
53+
// &#8217; is a right single quotation mark — counts as 1 char
54+
expect(visibleBodyLength("&#8217;")).toBe(1);
55+
// 10 of them = 10 visible chars
56+
const ten = "&#8217;".repeat(10);
57+
expect(visibleBodyLength(ten)).toBe(10);
58+
});
59+
60+
it("decodes hex numeric entities as single chars", () => {
61+
// &#x2019; is the same right single quotation mark
62+
expect(visibleBodyLength("&#x2019;")).toBe(1);
63+
expect(visibleBodyLength("&#x0026;")).toBe(1);
64+
});
65+
66+
it("does not throw on an invalid or out-of-range numeric entity", () => {
67+
expect(() => visibleBodyLength("&#999999999999;")).not.toThrow();
68+
expect(() => visibleBodyLength("&#x110000;")).not.toThrow();
69+
});
70+
71+
it("keeps thin body correctly flagged after entity decode", () => {
72+
// body is 10 &quot; chars = 10 visible chars; gate at 20 → thin
73+
const body = "&quot;".repeat(10);
74+
expect(isThinIssue(body, 20)).toBe(true);
75+
});
76+
4577
it("collapses whitespace runs and trims", () => {
4678
expect(visibleBodyLength(" a \n\t b ")).toBe(3);
4779
});

0 commit comments

Comments
 (0)