На днях столкнулся с такой ситуацией: паттерн, который гарантированно должен обеспечивать непустой вывод, вместо текста производит множество пустых строк.

Уже сталкивался с этим, но давно и причину вспомнил не сразу. Пофрустрировав, всё-таки припомнил, разобрался детальнее и решил написать небольшую заметку.

TL;DR: Причина – встреча символа возврата каретки CR (\r) с управляющей последовательностью \x1B[K : CR перемещает курсор в начало строки, а \x1B[K удаляет всё от курсора до конца строки. ОС – Ubuntu 24.04, терминал Terminator 2.1.3, шелл Bash.

Воспроизводим

Думая написать что-нибудь о разных способах извлечения информации из текстовых журналов, взял вот этот сэмпл системного лога Linux, чтобы на нём поупражняться:

https://raw.githubusercontent.com/logpai/loghub/refs/heads/master/Linux/Linux_2k.log

Из этого репозитория:

https://github.com/logpai/loghub

Выполнение следующей команды даёт интересный вывод:

grep -Po 'authentication failure.*?rhost=\K.*' Linux_2k.log
Пустые строки, но не промпт
Пустые строки, но не промпт

Это признак того, что паттерн работает – в противном случае я сразу получил бы следующий промпт. По идее, я должен был получить 490 строк вывода:

$ grep -Po 'authentication failure.*?rhost=\K.*' Linux_2k.log | wc -l
490

А если отправляю вывод в tail , то его видно (забегая вперёд – это из-за того, что автоподсветка grep отключает управляющие последовательности, когда вывод идёт в пайп):

$ grep -Po 'authentication failure.*?rhost=\K.*' Linux_2k.log | tail -5
207.243.167.114  user=root
207.243.167.114  user=root
207.243.167.114  user=root
207.243.167.114  user=root
207.243.167.114  user=root

А также, если выполнить "захват" не до конца строки, а, скажем, всего три символа:

$ grep -Po 'authentication failure.*?rhost=\K...' Linux_2k.log
218
218
220
220
220
# ... и т.д.

Причина

Примеры логов хранятся в репозитории с разделителями строк стиля Windows – CRLF (\r\n ) . Это последовательность символов возврата каретки (Carriage Return) и перевода строки (Line Feed). По работе я практически не сталкиваюсь с необходимостью что-то искать в таких файлах, да и дома тоже. А тут ещё и файл с системным логом Linux... Так что вывод file сопроводился лёгким когнитивным диссонансом:

$ file Linux_2k.log 
Linux_2k.log: ASCII text, with CRLF line terminators

Настало время посмотреть, что на самом деле происходит, когда я запускаю grep:

$ alias | grep grep
alias egrep='egrep --color=auto'
alias fgrep='fgrep --color=auto'
alias grep='grep --color=auto'

Эти алиасы заданы в ~/.bashrc:

$ grep grep ~/.bashrc
    alias grep='grep --color=auto'
    alias fgrep='fgrep --color=auto'
    alias egrep='egrep --color=auto'

Включена автоподсветка:

Автоподсветка
Автоподсветка

Выключаем, и получаем ожидаемый вывод:

$ grep --color=never -Po 'authentication failure.*?rhost=\K.*' Linux_2k.log
218.188.2.4 
218.188.2.4 
220-135-151-1.hinet-ip.hinet.net  user=root
220-135-151-1.hinet-ip.hinet.net  user=root
# ... и т.д.

Почему так происходит?

Для управления цветом и другими свойствами вывода в консоли Linux используются "управляющие последовательности" – текст, не отображающийся, а интерпретирующийся как команда:
https://www.man7.org/linux/man-pages/man4/console_codes.4.html

Т.е., при включении подсветки grep может сгенерировать вывод так, что встреча с разделителем строк CRLF приведёт к неожиданному поведению. С помощью cat -A можно посмотреть на все символы вывода:

$ grep --color=always -Po 'authentication failure.*?rhost=\K.*' Linux_2k.log \
| cat -A | tail -5
^[[01;31m^[[K207.243.167.114  user=root^M^[[m^[[K$
^[[01;31m^[[K207.243.167.114  user=root^M^[[m^[[K$
^[[01;31m^[[K207.243.167.114  user=root^M^[[m^[[K$
^[[01;31m^[[K207.243.167.114  user=root^M^[[m^[[K$
^[[01;31m^[[K207.243.167.114  user=root^M^[[m^[[K$

Пример выше с перенаправлением вывода в tail, когда вывод становился видимым, работал из-за --color=auto в алиасе – когда grep пишет в консоль, он включает подсветку, а когда в пайп – выключает:

$ grep --color=auto -Po 'authentication failure.*?rhost=\K.*' Linux_2k.log \
| cat -A | tail -5
207.243.167.114  user=root^M$
207.243.167.114  user=root^M$
207.243.167.114  user=root^M$
207.243.167.114  user=root^M$
207.243.167.114  user=root^M$

Разберём, что происходит в этой строке:

^[[01;31m^[[K207.243.167.114  user=root^M^[[m^[[K$

Знаком доллара cat -Aобозначает перевод строки LF. Это стандартный разделитель строк в системах Linux, благодаря ему следующая строка выводится под текущей.

Остальные управляющие последовательности и символы указаны в каретной нотации:

https://ru.wikipedia.org/wiki/Каретная_нотация

Чтобы преобразовать их обратно в символы и последовательности, управляющие выводом в терминале, нужно заменить: ^[ на \x1B, ^M на \r.

\x1B это шестнадцатиричная репрезентация символа ESC из таблицы ASCII:
https://www.ascii-code.com/

Этот символ начинает т.н. escape-последовательность, переводящую терминал в режим выполнения последующих команд. Его ещё обычно можно записывать, как \e или в восьмеричной форме \033 . Следующий за ESC символ [ маркирует начало введения управляющей последовательности (CSI – Control Sequence Introducer).

Когда после CSI встречается символ m , мы имеем дело с SGR (Select Graphic Rendition) - параметрами, управляющими визуальными свойствами вывода. Эти параметры записываются между CSI (\x1B[ ) и символом m. Параметров может быть несколько, они разделяются символом ;.

Итак, слева направо:

\x1B[01;31m – это управляющая последовательность SGR, задающая два свойства: 01 – жирный шрифт, 31 – красный цвет текста.

\x1B[K – указание терминалу удалить текст от позиции курсора до конца строки. Называется EL (Erase Line).

После этого выводится, собственно, искомый текст – 207.243.167.114 user=root .

\r – возврат каретки CR , указание терминалу перевести курсор в начало строки.

\x1B[m – последовательность SGR без параметров интерпретируется как \x1B[0m , reset, т.е. сброс визуальных параметров вывода к значениям по умолчанию. Без этой команды весь прочий текст продолжал бы выводится жирным шрифтом красного цвета.

\x1B[K – и снова указание терминалу удалить текст от позиции курсора до конца строки. При выводе курсор находится там, куда будет печататься следующий отображаемый символ. Но \r уже переместил курсор к началу строки, и до этого момента отображаемых символов не было, поэтому курсор остаётся там. А терминал, повинуясь \x1B[K , удаляет весь текст – от начала до конца строки. Включая, разумеется, 207.243.167.114 user=root .

Проделаем теперь замену каретной нотации, чтобы проверить:

$ grep --color=always -Po 'authentication failure.*?rhost=\K.*' Linux_2k.log \
| cat -A \
| tail -5 \
| sed -E 's/\^\[/\x1B/g; s/\^M/\r/g'
$
$
$
$
$
$ 

Первый и последний знаки доллара – это промпт. Остальные – остатки вывода cat -A , обозначающие перевод строки.

Обладая этой информацией, можно тривиально воспроизвести этот эффект без всяких логов, вручную:

$ echo -e "asdf\r\x1B[K"

$ echo -e "asdf\x1B[K"
asdf
$ echo -e "asdf\r\n\x1B[K"
asdf

$

И что с этим делать?

Можно преобразовать файл к разделителям строк LF:

$ file Linux_2k.log 
Linux_2k.log: ASCII text, with CRLF line terminators
$ 
$ grep --color=always -Po 'authentication failure.*?rhost=\K.*' Linux_2k.log | tail -1

$ dos2unix Linux_2k.log 
dos2unix: converting file Linux_2k.log to Unix format...
$ 
$ file Linux_2k.log 
Linux_2k.log: ASCII text
$ 
$ grep --color=always -Po 'authentication failure.*?rhost=\K.*' Linux_2k.log | tail -1
207.243.167.114  user=root
До и после dos2unix
До и после dos2unix

Правда, dos2unix может не присутствовать по умолчанию. Но в популярных дистрибутивах она должна тривиально устанавливаться из репозитория.

Если по какой-то причине файл менять нельзя, то можно использовать переключатель grep --color=never ... либо в отдельных командах, либо поправить алиасы в .bashrc.

Ещё можно использовать переменную окружения GREP_COLORS=ne:

$ unix2dos Linux_2k.log 
unix2dos: converting file Linux_2k.log to DOS format...
$ 
$ grep --color=always -Po 'authentication failure.*?rhost=\K.*' Linux_2k.log | tail -1

$ 
$ GREP_COLORS=ne grep --color=always -Po 'authentication failure.*?rhost=\K.*' Linux_2k.log | tail -1
207.243.167.114  user=root
$ 

Эта возможность документирована в grep(1). Значение ne – отменяет использование \x1B[K:

$ GREP_COLORS=ne grep --color=always -Po 'authentication failure.*?rhost=\K.*' Linux_2k.log \
| cat -A | tail -1
^[[01;31m207.243.167.114  user=root^M^[[m$

Если почему-то нельзя трогать файл или установить dos2unix, то я бы выбрал отмену подсветки, т.к. на практике особо ей не пользуюсь.

А зачем, вообще, там EL?

Как я понимаю, для легаси-терминалов и аномальных ситуаций, когда на строке вывода уже есть текст или по какой-то причине не сбрасывается цвет. Несколько попыток воспроизвести аномальное поведение результатов не дали.

Если интересно покопаться в этом, в grep-3.11/src/grep.c, который можно взять отсюда – https://www.gnu.org/software/grep/ – есть об этом довольно длинный комментарий, начиная с 310-й строки.

Комментарии (2)


  1. Anguycat
    23.01.2025 16:39

    Спасибо, теперь стало понятно, почему у меня не грепались логи


    1. a1111exe Автор
      23.01.2025 16:39

      Не за что!
      Кстати, нагуглил, что в экосистеме Мак уже перешли с разделителей CR на LF. Эх, вот бы теперь и Майкрософт последовали этому примеру... :)