final cleanup regexps

This commit is contained in:
Ilya Kantor 2015-04-07 15:22:06 +03:00
parent 59388d093e
commit 833f7ba70e
132 changed files with 410 additions and 183 deletions

View file

@ -0,0 +1,303 @@
# Чёрная дыра бэктрекинга
Некоторые регулярные выражения, с виду являясь простыми, могут выполняться оооочень долго, и даже "подвешивать" интерпретатор JavaScript.
Рано или поздно, с этим сталкивается любой разработчик, потому что нечаянно создать такое регулярное выражение -- легче лёгкого.
Типична ситуация, когда регулярное выражение до поры до времени работает нормально, и вдруг на каком-то тексте как начнёт "подвешивать" интерпретатор и есть 100% процессора.
Это может стать уязвимостью. Например, если JavaScript выполняется на сервере, то при разборе данных, присланных посетителем, он может зависнуть, если использует подобный регэксп. На клиенте тоже возможно подобное, при использовании регэкспа для подсветки синтаксиса.
Такие уязвимости "убивали" почтовые сервера и системы обмена сообщениями и до появления JavaScript, и наверно будут "убивать" и после его исчезновения. Так что мы просто обязаны с ними разобраться.
[cut]
## Пример
План изложения у нас будет таким:
<ol>
<li>Сначала посмотрим на проблему в реальной ситуации.</li>
<li>Потом упростим реальную ситуацию до "корней" и увидим, откуда она берётся.</li>
</ol>
Рассмотрим, например, поиск по HTML.
Мы хотим найти теги с атрибутами, то есть совпадения вида <code class="subject">&lt;a href="..." class=doc ...&gt;</code>.
Самый простой способ это сделать -- <code class="pattern">&lt;[^>]*&gt;</code>. Но он же и не совсем корректный, так как тег может выглядеть так: <code class="subject">&lt;a test="&lt;&gt;" href="#"&gt;</code>. То есть, внутри "закавыченного" атрибута может быть символ `>`. Простейший регэксп на нём остановится и найдёт <code class="match">&lt;a test="&lt;&gt;</code>.
Соответствие:
```
<[^>]*....>
<a test="<>" href="#">
```
А нам нужен весь тег.
Для того, чтобы правильно обрабатывать такие ситуации, нужно учесть их в регулярном выражении. Оно будет иметь вид <code class="pattern">&lt;тег (ключ=значение)*&gt;</code>.
Если перевести на язык регэкспов, то: <code class="pattern">&lt;\w+(\s*\w+=(\w+|"[^"]*")\s*)*&gt;</code>:
<ol>
<li><code class="pattern">&lt;\w+</code> -- начало тега</li>
<li><code class="pattern">(\s*\w+=(\w+|"[^"]*")\s*)*</code> -- произвольное количество пар вида `слово=значение`, где "значение" может быть также словом <code class="pattern">\w+</code>, либо строкой в кавычках <code class="pattern">"[^"]*"</code>.</li>
</ol>
Мы пока не учитываем все детали грамматики HTML, ведь строки возможны и в 'одинарных' кавычках, но на данный момент этого достаточно. Главное, что регулярное выражение получилось в меру простым и понятным.
Испытаем полученный регэксп в действии:
```js
//+ run
var reg = /<\w+(\s*\w+=(\w+|"[^"]*")\s*)*>/g;
var str='...<a test="<>" href="#">... <b>...';
alert( str.match(reg) ); // <a test="<>" href="#">, <b>
```
Отлично, всё работает! Нашло как длинный тег <code class="match">&lt;a test="&lt;&gt;" href="#"&gt;</code>, так и одинокий <code class="match">&lt;b&gt;</code>.
А теперь -- демонстрация проблемы.
Если запустить пример ниже, то он может подвесить браузер:
```js
//+ run
var reg = /<\w+(\s*\w+=(\w+|"[^"]*")\s*)*>/g;
var str = "<tag a=b a=b a=b a=b a=b a=b a=b a=b \
a=b a=b a=b a=b a=b a=b a=b a=b a=b a=b a=b a=b a=b";
*!*
// Этот поиск будет выполняться очень, очень долго
alert( str.match(reg) );
*/!*
```
Некоторые движки регулярных выражений могут в разумное время разобраться с таким поиском, но большинство -- нет.
В чём дело? Почему несложное регулярное выражение на такой небольшой строке "виснет" наглухо?
Упростим ситуацию, удалив тег и возможность указывать строки в кавычках:
```js
//+ run
// только атрибуты, разделённые пробелами
var reg = /<(\s*\w+=\w+\s*)*>/g;
var str = "<a=b a=b a=b a=b a=b a=b a=b a=b \
a=b a=b a=b a=b a=b a=b a=b a=b a=b a=b a=b a=b a=b";
*!*
// Этот поиск будет выполняться очень, очень долго
alert( str.match(reg) );
*/!*
```
То же самое.
На этом мы закончим с демонстрацией "практического примера" и перейдём к разбору происходящего.
## Бектрекинг
В качестве ещё более простого регулярного выражения, рассмотрим <code class="pattern">(\d+)*$</code>.
В большинстве движков регэкспов, например в Chrome или IE, этот поиск выполняется очень долго (осторожно, может "подвесить" браузер):
```js
//+ run
alert( '12345678901234567890123456789123456789z'.match(/(\d+)*$/) );
```
В чём же дело, что не так с регэкспом?
Внимательный читатель, посмотрев на него, наверняка удивится, ведь он "какой-то странный". Квантификатор <code class="pattern">*</code> здесь выглядит лишним.
Если хочется найти число, то с тем же успехом можно искать <code class="pattern">\d+$</code>.
Да, этот регэксп носит искусственный характер, но, разобравшись с ним, мы поймём и практический пример, данный выше. Причина их медленной работы одинакова.
В целом, с регэкспом "всё так", синтаксис вполне допустимый. Проблема в том, как выполняется поиск по нему.
Посмотрим, что происходит при поиске в строке <code class="subject">123456789z</code>:
<ol>
<li>Первым делом, движок регэкспов пытается найти <code class="pattern">\d+</code>. Плюс <code class="pattern">+</code> является жадным по умолчанию, так что он хватает все цифры, какие может:
```
\d+.......
(123456789)z
```
</li>
<li>Затем движок пытается применить звёздочку вокруг скобок <code class="pattern">(\d+)*</code>, но больше цифр нет, так что звёздочка не даёт повторений.
Затем в шаблоне идёт символ конца строки <code class="pattern">$</code>, а в тексте -- символ <code class="subject">z</code>.
```
X
\d+........$
(123456789)z
```
Соответствия нет.
</li>
<li>Так как соответствие не найдено, то "жадный" плюс <code class="pattern">+</code> отступает на один символ (бэктрекинг).
Теперь `\d+` -- это все цифры, за исключением последней:
```
\d+.......
(12345678)9z
```
</li>
<li>После бэктрекинга, <code class="pattern">\d+</code> содержит всё число, кроме последней цифры. Движок снова пытается найти совпадение, уже с новой позиции (`9`).
Звёздочка <code class="pattern">(\d+)*</code> теперь может быть применена -- она даёт число <code class="match">9</code>:
```
\d+.......\d+
(12345678)(9)z
```
Движок пытается найти `$`, но это ему не удаётся -- на его пути опять `z`:
```
X
\d+.......\d+
(12345678)(9)z
```
Так как совпадения нет, то поисковой движок отступает назад ещё раз.
</li>
<li>Теперь первое число <code class="pattern">\d+</code> будет содержать 7 цифр, а остаток строки <code class="subject">89</code> становится вторым <code class="pattern">\d+</code>:
```
X
\d+......\d+
(1234567)(89)z
```
Увы, всё ещё нет соответствия для <code class="pattern">$</code>.
Поисковой движок снова должен отступить назад. При этом последний жадный квантификатор отпускает символ. В данном случае это означает, что укорачивается второй <code class="pattern">\d+</code>, до одного символа <code class="subject">8</code>, и звёздочка забирает следующий <code class="subject">9</code>.
```
X
\d+......\d+\d+
(1234567)(8)(9)z
```
</li>
<li>...И снова неудача. Второе и третье <code class="pattern">\d+</code> отступили по-максимуму, так что сокращается снова первое число, до <code class="subject">123456</code>, а звёздочка берёт оставшееся:
```
X
\d+.......\d+
(123456)(789)z
```
Снова нет совпадения. Процесс повторяется, последний жадный квантификатор <code class="pattern">+</code> отпускает один символ (`9`):
```
X
\d+.....\d+ \d+
(123456)(78)(9)z
```
</li>
...И так далее.
Получается, что движок регулярных выражений перебирает все комбинации из `123456789` и их подпоследовательности. А таких комбинаций очень много.
На этом месте умный читатель может воскликнуть: "Во всём виноват бэктрекинг? Давайте включим ленивый режим -- и не будет никакого бэктрекинга!"
Что ж, заменим <code class="pattern">\d+</code> на <code class="pattern">\d+?</code> и посмотрим (аккуратно, может подвесить браузер):
```js
//+ run
alert( '12345678901234567890123456789123456789z'.match(/(\d+?)*$/) );
```
Не помогло!
**Ленивые регулярные выражения делают то же самое, но в обратном порядке.**
Просто подумайте о том, как будет в этом случае работать поисковой движок.
Некоторые движки регулярных выражений содержат хитрые проверки и конечные автоматы, которые позволяют избежать бесконечного перебора или кардинально ускорить его, но все движки и не всегда.
Возвращаясь к примеру выше -- при поиске <code class="pattern">&lt;(\s*\w+=\w+\s*)*&gt;</code> в строке <code class="subject">&lt;a=b a=b a=b a=b</code> происходит то же самое.
Поиск успешно начинается, выбирается некая комбинация из <code class="pattern">\s*\w+=\w+\s*</code>, которая, так как в конце нет `>`, оказывается не подходящей. Движок честно отступает, пробует другую комбинацию -- и так далее.
## Что делать?
Проблема -- в сверхмноговариантном переборе.
Движок регулярных выражений перебирает кучу возможных вариантов скобок там, где это не нужно.
Например, в регэкспе <code class="pattern">(\d+)*$</code> нам (людям) очевидно, что в <code class="pattern">(\d+)</code> откатываться не нужно. От того, что вместо одного <code class="pattern">\d+</code> у нас два независимых <code class="pattern">\d+\d+</code>, ничего не изменится.
Без разницы:
```
\d+........
(123456789)z
\d+...\d+....
(1234)(56789)z
```
Если вернуться к более реальному примеру <code class="pattern">&lt;(\s*\w+=\w+\s*)*&gt;</code> то
cам алгоритм поиска, который у нас в голове, предусматривает, что мы "просто" ищем тег, а потом пары `атрибут=значение` (сколько получится).
Никакого "отката" здесь не нужно.
В современных регулярных выражениях для решения этой проблемы придумали "possessive" (сверхжадные? неоткатные? точный перевод пока не устоялся) квантификаторы, которые вообще не используют бэктрегинг.
То есть, они даже проще, чем "жадные" -- берут максимальное количество символов и всё. Поиск продолжается дальше. При несовпадении никакого возврата не происходит.
Это, c стороны уменьшает количество возможных результатов, но с другой стороны -- в ряде случаев очевидно, что возврат (уменьшение количество повторений квантификатора) результата не даст. А только потратит время, что как раз и доставляет проблемы. Как раз такие ситуации и описаны выше.
Есть и другое средство -- "атомарные скобочные группы", которые запрещают перебор внутри скобок, по сути позволяя добиваться того же, что и сверхжадные квантификаторы,
К сожалению, в JavaScript они не поддерживаются.
Однако, можно получить подобный эффект при помощи предпросмотра. Подробное описание соответствия с учётом синтаксиса сверхжадных квантификаторов и атомарных групп есть в статьях [Regex: Emulate Atomic Grouping (and Possessive Quantifiers) with LookAhead](http://instanceof.me/post/52245507631/regex-emulate-atomic-grouping-with-lookahead) и [Mimicking Atomic Groups](http://blog.stevenlevithan.com/archives/mimic-atomic-groups), здесь же мы останемся в рамках синтаксиса JavaScript.
Взятие максимального количества повторений `a+` без отката выглядит так: <code class="pattern">(?=(a+))\1</code>.
То есть, иными словами, предпросмотр <code class="pattern">?=</code> ищет максимальное количество повторений <code class="pattern">a+</code>, доступных с текущей позиции. А затем они "берутся в результат" обратной ссылкой <code class="pattern">\1</code>. Дальнейший поиск -- после найденных повторений.
Откат в этой логике принципе не предусмотрен, поскольку предпросмотр "откатываться" не умеет. То есть, если предпросмотр нашёл 5 штук <code class="pattern">a+</code>, и в результате поиск не удался, то он не будет откатываться на 4 повторения. Эта возможность в предпросмотре отсутствует, а в данном случае она как раз и не нужна.
Исправим регэксп для поиска тега с атрибутами <code class="pattern"><\w+(\s*\w+=(\w+|"[^"]*")\s*)*></code>, описанный в начале главы. Используем предпросмотр, чтобы запретить откат на меньшее количество пар `атрибут=значение`:
```js
//+ run
// регэксп для пары атрибут=значение
var attr = /(\s*\w+=(\w+|"[^"]*")\s*)/
// используем его внутри регэкспа для тега
var reg = new RegExp('<\\w+(?=(' + attr.source + '*))\\1>', 'g');
var good = '...<a test="<>" href="#">... <b>...';
var bad = "<tag a=b a=b a=b a=b a=b a=b a=b a=b\
a=b a=b a=b a=b a=b a=b a=b a=b a=b a=b a=b a=b a=b";
alert( good.match(reg) ); // <a test="<>" href="#">, <b>
alert( bad.match(reg) ); // null (нет результатов, быстро)
```
Отлично, всё работает! Нашло как длинный тег <code class="match">&lt;a test="&lt;&gt;" href="#"&gt;</code>, так и одинокий <code class="match">&lt;b&gt;</code>.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB