You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
127 lines
3.8 KiB
127 lines
3.8 KiB
import type { ArraySplitMode } from "@/types";
|
|
|
|
export interface ParseArrayOptions {
|
|
splitMode: ArraySplitMode;
|
|
delimiter: string;
|
|
regexPattern: string;
|
|
trimItems: boolean;
|
|
removeEmpty: boolean;
|
|
}
|
|
|
|
export interface ParseArrayResult {
|
|
items: string[];
|
|
error: string | null;
|
|
}
|
|
|
|
const MAX_REGEX_PATTERN_LENGTH = 100;
|
|
const MAX_REGEX_INPUT_LENGTH = 100_000;
|
|
|
|
/**
|
|
* Detect regex patterns prone to catastrophic backtracking (ReDoS).
|
|
* Rejects nested quantifiers like (a+)+, (a*)+, ((a+))+, etc.
|
|
* Uses character-by-character parsing to track nesting depth.
|
|
*/
|
|
function isUnsafePattern(pattern: string): boolean {
|
|
const slashFormat = pattern.match(/^\/(.+)\/[a-z]*$/i);
|
|
const body = slashFormat ? slashFormat[1] : pattern;
|
|
|
|
// Track groups: when we see ')' followed by a quantifier,
|
|
// check if anything inside that group also had a quantifier.
|
|
let depth = 0;
|
|
const quantifierAtDepth: boolean[] = [];
|
|
|
|
for (let i = 0; i < body.length; i++) {
|
|
const ch = body[i];
|
|
if (ch === '\\') { i++; continue; } // skip escaped chars
|
|
if (ch === '(') {
|
|
depth++;
|
|
quantifierAtDepth[depth] = false;
|
|
} else if (ch === ')') {
|
|
const hadQuantifier = quantifierAtDepth[depth] || false;
|
|
depth = Math.max(0, depth - 1);
|
|
// Check if this closing paren is followed by a quantifier
|
|
const next = body[i + 1];
|
|
if (next === '+' || next === '*' || next === '{') {
|
|
if (hadQuantifier) return true; // nested quantifier!
|
|
// Mark parent depth as having a quantifier
|
|
quantifierAtDepth[depth] = true;
|
|
} else if (hadQuantifier) {
|
|
// Group contained quantifier but isn't followed by one - propagate to parent
|
|
quantifierAtDepth[depth] = true;
|
|
}
|
|
} else if ((ch === '+' || ch === '*') && depth > 0) {
|
|
quantifierAtDepth[depth] = true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function parseRegexPattern(pattern: string): RegExp {
|
|
// Supports `/pattern/flags` and plain `pattern`.
|
|
const slashFormat = pattern.match(/^\/(.+)\/([a-z]*)$/i);
|
|
if (slashFormat) {
|
|
return new RegExp(slashFormat[1], slashFormat[2]);
|
|
}
|
|
return new RegExp(pattern);
|
|
}
|
|
|
|
export function parseTextToArray(
|
|
inputText: string | null | undefined,
|
|
options: ParseArrayOptions
|
|
): ParseArrayResult {
|
|
const source = inputText ?? "";
|
|
if (!source) {
|
|
return { items: [], error: null };
|
|
}
|
|
|
|
let rawItems: string[];
|
|
|
|
try {
|
|
if (options.splitMode === "newline") {
|
|
rawItems = source.split(/\r?\n/);
|
|
} else if (options.splitMode === "regex") {
|
|
if (!options.regexPattern) {
|
|
rawItems = [source];
|
|
} else if (options.regexPattern.length > MAX_REGEX_PATTERN_LENGTH) {
|
|
return {
|
|
items: [],
|
|
error: `Regex pattern too long (max ${MAX_REGEX_PATTERN_LENGTH} characters)`,
|
|
};
|
|
} else if (isUnsafePattern(options.regexPattern)) {
|
|
return {
|
|
items: [],
|
|
error: "Regex pattern rejected: nested quantifiers can cause catastrophic backtracking",
|
|
};
|
|
} else if (source.length > MAX_REGEX_INPUT_LENGTH) {
|
|
return {
|
|
items: [],
|
|
error: `Input too long for regex mode (max ${MAX_REGEX_INPUT_LENGTH.toLocaleString()} characters)`,
|
|
};
|
|
} else {
|
|
rawItems = source.split(parseRegexPattern(options.regexPattern));
|
|
}
|
|
} else {
|
|
// Delimiter mode
|
|
if (!options.delimiter) {
|
|
rawItems = [source];
|
|
} else {
|
|
rawItems = source.split(options.delimiter);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
items: [],
|
|
error: error instanceof Error ? error.message : "Invalid split pattern",
|
|
};
|
|
}
|
|
|
|
let items = rawItems;
|
|
if (options.trimItems) {
|
|
items = items.map((item) => item.trim());
|
|
}
|
|
if (options.removeEmpty) {
|
|
items = items.filter((item) => item.length > 0);
|
|
}
|
|
|
|
return { items, error: null };
|
|
}
|
|
|