/**
* HTML sanitization utilities — CodeQL-safe, ZERO regex.
*
* Previous versions used multi-character regex patterns for stripping, which
* CodeQL flagged as "Incomplete multi-character sanitization" (alerts #16-#20).
* The recommended fix is to either:
* (a) rewrite the regex to match single characters, OR
* (b) use a well-tested sanitization library.
*
* This module takes approach (a) to the extreme: it uses NO regex at all.
* All scanning is done via manual character-by-character iteration with
* `String.prototype.indexOf` / `startsWith` for exact substring matching.
* This eliminates the entire class of regex-bypass vulnerabilities.
*
* The stripper:
* 1. Scans the input character by character
* 2. Drops HTML comments () entirely
* 3. Unwraps CDATA sections () — inner content preserved
* 4. Drops '
const tagEnd = input.indexOf('>', i);
return { output: '', nextIndex: tagEnd === -1 ? len : tagEnd + 1 };
}
/** Skip a block tag like — returns position after closing tag. */
function skipBlockTag(input: string, i: number, closeTag: string): { output: string; nextIndex: number } {
const tagEnd = input.indexOf('>', i);
if (tagEnd === -1) return { output: '', nextIndex: input.length };
const closeIdx = indexOfIgnoreCase(input, closeTag, tagEnd + 1);
if (closeIdx === -1) return { output: '', nextIndex: input.length };
const afterClose = input.indexOf('>', closeIdx);
return { output: '', nextIndex: afterClose === -1 ? input.length : afterClose + 1 };
}
/** Process HTML entity (&...;) — decode or output literal '&'. */
function processEntity(input: string, i: number): { output: string; nextIndex: number } {
const semi = input.indexOf(';', i);
if (semi !== -1 && semi - i >= 2 && semi - i <= 33) {
const entity = input.slice(i, semi + 1);
const decoded = decodeEntity(entity);
if (decoded !== null) {
return { output: decoded, nextIndex: semi + 1 };
}
}
return { output: '&', nextIndex: i + 1 };
}
/**
* Collapse runs of ASCII whitespace (space, tab, CR, LF, FF, VT) into a single
* space, then trim leading/trailing whitespace. Non-breaking spaces (U+00A0)
* are preserved as intentional content.
*
* Manual implementation — no regex used.
*/
function collapseWhitespace(input: string): string {
const wsChars = new Set([' ', '\t', '\r', '\n', '\f', '\v']);
const len = input.length;
let out = '';
let inWs = false;
let start = 0;
let end = input.length;
// Find first non-ws char
while (start < len && wsChars.has(input[start])) start++;
// Find last non-ws char
while (end > start && wsChars.has(input[end - 1])) end--;
for (let i = start; i < end; i++) {
const c = input[i];
if (wsChars.has(c)) {
if (!inWs) {
out += ' ';
inWs = true;
}
// else: skip — we're in a whitespace run, only emit one space
} else {
out += c;
inWs = false;
}
}
return out;
}
// ---------------------------------------------------------------------------
// Pure string helpers (no regex used for security-sensitive scanning)
// ---------------------------------------------------------------------------
function startsWith(str: string, prefix: string, position: number): boolean {
return str.startsWith(prefix, position);
}
function startsWithIgnoreCase(str: string, prefix: string, position: number): boolean {
if (position + prefix.length > str.length) return false;
for (let j = 0; j < prefix.length; j++) {
const a = str[position + j];
const b = prefix[j];
if (a === b) continue;
if (a.toLowerCase() === b.toLowerCase()) continue;
return false;
}
return true;
}
function indexOfIgnoreCase(str: string, needle: string, fromIndex: number): number {
const lower = str.toLowerCase();
const lowerNeedle = needle.toLowerCase();
return lower.indexOf(lowerNeedle, fromIndex);
}
/**
* Truncate text to `maxLen` characters, appending an ellipsis if truncated.
* Counts visible characters (after entity decoding), not bytes.
*/
export function truncateText(text: string, maxLen: number): string {
if (text.length <= maxLen) return text;
if (maxLen <= 1) return text.slice(0, maxLen);
return text.slice(0, maxLen - 1) + '…';
}