Also spricht Kirikaza

Кодировка имён файлов в Apple HFS+

Это исследование началось со странного поведения git-а. Я создал в репозитории ветку по имени «wåt», запушил её и ушёл на другую. Когда я через некоторое время вытянул изменения, то сразу бросилось в глаза сообщение git-а о получении новой ветки «wåt». Почему новой? Повторил «git pull» несколько раз — а он несколько раз повторил, что скачал новую ветку «wåt». Но ведь такая ветка у меня есть и в неё никто не пушил. Хотя git со мной не был согласен: «git log origin/wåt --» отрицал, что ветка «wåt» скачана из отдалённого репозитория. Да и локальной ветки как будто не было, говорил мне «git log wåt --».

Наверняка всё дело в символе «å», но чем он хуже русской буквы «щ» или эмоджи «🐢», с которыми таких проблем нет? (Да, мы в Ecwid создаём ветки и с русскими буквами, и с эмоджи-символами.) Оказалось, git не виноват — это всё macOS, её файловая система HFS+ и Unicode. Но вернусь к началу исследования.

Итак, «git log wåt --» не знает такого имени, будь то ветка, тэг или что-то ещё. А в списке веток это имя вообще есть? «git branch --list 'wåt'» ничего не находит. И ведь не показалось мне, что я создал ветку, — она скачивается с сервера каждый раз. Может, git не осилил букву «å» и ветка называется «w⎕t»? Поищу все ветки, начинающиеся на «w» и заканчивающиеся на «t»:

% git branch --list 'w*t'
  wåt

Вот она, вот она! И вроде бы символы никакие не сломались. Может, git не умеет искать по таким «сложным» символам? Вот grep точно умеет, спрошу у него с помощью регулярных выражений:

% git branch --list | grep ' wåt$'
% git branch --list | grep ' w.*t$'
  wåt

Хм, неужели grep не может искать буквы разных алфавитов? Чем шведская «å» сложнее русской «й»?.. Может, «å» только с виду обычная буква, а внутри (на уровне байтов) что-то особенное? Обычно для разглядывания байтов я использую команду xxd:

% echo 'wåt' | xxd -ps
77c3a5740a

Команда xxd с ключом «-ps» выводит байты в шестнадцатиричном виде («77c3a574a0»). Каждый байт — это ровно две шестнадцатиричные цифры, так что здесь видно 5 байтов. Чтобы понять, как эти байты соотносятся с символами, придётся узнать (или вспомнить) кое-что о кодировке UTF-8.

Каждый символ в UTF-8 строится из одного или нескольких 8-битных блоков, то есть из байтов. Каждая буква латинского алфавита представлена в UTF-8 одним байтом, равно как и каждая цифра, пробел или символ конца строки. Последний имеет код A16 (кому-то привычнее видеть 0xA или 0x0A), пробел имеет код 2016; цифра «0» — 3016, «1» — 3116, «9» — 3916; латинская буква «a» — 6116, «b» — 6216, «y» — 7916, «z» — 7A16. А национальные символы других алфавитов представлены уже последовательностями из нескольких байтов (чаще всего двух или трёх).

Хватит ликбеза, смотрим на последовательность байтов. В начале идёт 7716 (код символа «w»); в конце — 0A16 (символ конца строки); перед ним — 7416 («t»). Остались только два байта C316 и A516, значит, они и представляют символ «å»; другими словами, он имеет код C3A516. Проверю с помощью «echo» и специального флага «-e», позволяющего явно задавать байт по его коду:

% echo -e '\xC3\xA5'
å

Да, всё верно, это тот самый символ; только почему его grep не находит, если его явно указать? Когда-то давно grep плохо поддерживал UTF-8 и не умел искать многобайтовые символы; но уже лет десять как исправлено. Интересно, а по регулярке «w.t» grep найдёт?

% git branch --list | grep ' w.t$'
%

Хм, не нашёл. А если бы там была русская буква, тоже кодируемая двумя байтами?

% echo 'wыt' | grep 'w.t$'
wыt
% echo 'wыt' | xxd -ps
77d18b740a

То есть русскую букву grep находит, в чём же разница? Проверю снова с буквой «å»:

% echo 'wåt' | grep 'w.t$'
wåt
% echo 'wåt' | xxd -ps
77c3a5740a

А теперь находит. Только ведь «теперь» — это в тексте, который набрал я. А изначально grep не мог найти «å» в выводе команды «git branch --list» — этот вывод и надо разглядеть побайтово.

% git branch --list | grep ' w.*t$' | xxd -ps
20207761cc8a740a

Тут cначала идут два пробела (2016), на которые не стоит обращать внимания, просто git выводит список веток с отступом. Затем идёт буква «w» (7716), а в самом конце — буква «t» (7416) и символ конца строки (A16); между ними действительно три байта (6116, CC16 и 8A16). Это один символ из трёх байтов? Или несколько символов? Первый байт — это 6116, или 011000012. Вообще это код латинской буквы «a». Но можно ли его оторвать от следующих двух байтов — вдруг это часть многобайтовой последовательности? Чтобы ответить на этот вопрос, надо обратить внимание на старший (самый левый) бит: если он сброшен (то есть равен нулю), то один байт целиком определяет символ. В данному случае старший бит 0, так что этот символ — действительно латинская буква «a», самая обычная, безо всяких кружков. А куда делся кружочек?.. Ладно, посмотрим дальше, вдруг понятнее станет. Следующий байт — CC16, или 110011002. У него старший бит установлен, поэтому символ задан не одним байтом, а несколькими, причём длина последовательности равна количеству установленных старших битов, иначе говоря, количеству единичек слева. В 110011002 слева две единицы, поэтому символ задан двумя байтами. «Их у нас есть!» Что же это за символ с кодом CC8A16?

% echo -e '\xCC\x8A'

Хм, ничего не видно. А если после буквы «a»?

% echo -e 'a\xCC\x8A'
å

Ух ты! А после других букв?

% echo -e 'y\xCC\x8A'
ẙ
% echo -e 'm\xCC\x8A'
m̊

Как будто это символ «добавь кружок над предыдущей буквой». Оказывается, так и есть; его точное название — «combining ring above», то есть «комбинируемый кружок сверху». Таких символов полно: крышки, штрихи, точки, хвостики и так далее.

Получается, что одну и ту же шведскую букву можно отобразить либо одним символом «å» с кодом C3A516, либо комбинацией из «a» (6116) и « ̊ » (CC8A16). Комбинирование происходит уже при отображении и зависит от конкретных символов, шрифта и программы (например, браузера); в каких-то ситуациях может выглядеть неаккуратно или вообще не работать. Первый вариант (одним символом) называется «precomposed», а второй — «decomposed» (мне не удалось найти хороший перевод для этих терминов). На заре Unicode для каждой буквы очередного алфавита вводили новый символ с выделенным для него кодом, но потом символов стало слишком много, хотя отличались они от уже имевшихся лишь каким-нибудь кружочком или хвостиком. Так что решили впредь символы новые не вводить, а использовать комбинации из базовых символов и комбинирующих. Но и от precomposed-символов отказаться нельзя, ведь далеко не все шрифты и программы поддерживают комбинирование символов.

А если я читаю PDF-ку с инструкцией по сборке на шведском и мне надо найти по тексту слово «får», то мне искать «får» или «får»? Ну ладно, допустим, не мне, а самой программе для PDF-ок — я-то не хочу думать о кодировках. На помощь приходит нормализация Unicode. Если в тексте все precomposed-символы превратить в эквивалентные им decomposed-комбинации (то есть из «å» сделать «a ̊ »), то таким образом получится нормальная форма D (от слова «decomposed»). Если же наоборот каждую decomposed-комбинацию превратить в один precomposed-символ (из «a ̊ » сделать «å»), то получится нормальная форма C (от «precomposed»). Программа перед поиском должна перевести в одну форму и весь текст документа, и искомое слово — тогда поиск слова по тексту станет тривиальным.

Но пора вернуться к исходной проблеме: git не находит ветку «wåt», где «å» представлена одним precomposed-символом с кодом C3A516. Но зато git знает ветку «wåt», где «å» представлена decomposed-комбинацией латинской буквы «a» и кружка « ̊ ». Видимо, когда я попросил создать ветку с precomposed-символом, git создал её с decomposed-комбинацией. Но тогда непонятно, почему она с сервера приходит каждый раз заново. Тут я решил порыться в исходниках git-а… и немного заблудился. Я не нашёл преобразования имени в нормальную форму D, но и не был уверен, что подобного преобразования там нет. (Всё-таки Си вынуждает программиста загромождать код низкоуровневыми нюансами, отвлекающими от сути.) В интернете ответ оказалось найти куда проще. Как я писал в самом начале, git оказался не причём: это macOS, создавая файлы на файловой системе HFS+, преобразует имена в нормальную форму D. Конечно, это надо проверить.

Если я создам в macOS (Sierra или старее) файл с именем «ой», а потом поищу его grep-ом — найдётся ли он?

% touch ой
% ls -lA
total 0
-rw-r--r--  1 kirikaza  staff  0  2 ноя 19:38 ой
% ls -lA | grep ой
%

Ой, не нашёлся. И всё по той же причине: я велел создать файл с precomposed-символом «й», а macOS создала файл с decomposed-комбинацией буквы «и» полукружка « ̆ ».

Так же получилось и с git-ом, который каждую ветку хранит в виде одноимённого файла в .git/refs/heads. Когда я попросил его создать ветку с precomposed-символом «å», он сохранил её в файл с таким же, как он думал, именем, но macOS перехитрила его и создала файл с decomposed-комбинацией «a» и « ̊ ». Таким образом, в репозитории оказалась ветка, в имени которой decomposed-комбинация, а не тот символ, который я просил.

Вот и раскрыта тайна исчезнувшей ветки. Забавно, что я не натолкнулся на эту интересную особенность, когда создавал ветки с русскими буквами в именах: у меня не было веток с буквами «ё» или «й».

Ключевым фактором здесь является файловая система HFS+. Когда я обновил macOS до версии «High Sierra», она преобразовала раздел в новую файловую систему APFS, которая не преобразует имена в нормальную форму, а хранит их как есть. Так что на APFS уже не воспроизвести эту проблему… если только файл не был создан до обновления macOS. Получается, APFS как-то по-особенному хранит в себе старые (созданные на HFS+) файлы, но это уже совсем другая история.

Полезные ссылки: