Punycode на PHP без расширения intl
Домены ♡
Браузер вежливо показывает мерка.рф, а DNS и curl видят xn--80ajpgv.xn--p1ai. Кириллицу в имени домена в DNS напрямую не передать, поэтому её, как карету Золушки, превращают в латиницу по правилам Punycode. На портале это нужно, например, чтобы проверить адрес, который вводит посетитель.
Зачем писать своё, если есть intl
Готовая функция есть: idn_to_ascii() из расширения intl. Только расширение стоит не на каждом хостинге, а портал должен заводиться где угодно, хоть на утюге. Поэтому в комментарии к классу Punycode прямо написано: работает без intl.
Такая же логика нужна и в браузере, для инструмента на странице. Её копия лежит в public/js/packs/code.js, а следит, чтобы обе версии не разбежались, тест из статьи про PHP и JavaScript.
Как это выглядит
Что выдаёт класс на настоящих доменах:
- xn--80ajpgv мерка (часть до точки)
- xn--p1ai рф
- xn--bcher-kva bücher: латиница остаётся, «ü» уезжает в хвост
Как работает кодирование
Идея RFC 3492 проще, чем можно подумать, глядя на его вид. Сначала выписываются все ASCII-символы метки, потом ставится дефис, а потом идёт «хвост» из цифр в системе по основанию 36. Хвост описывает, какой не-ASCII символ и в какую позицию надо вставить.
Возьмём «bücher»: ASCII-часть это bcher, а хвост kva говорит «вставь ü на второе место». Получается bcher-kva, а с префиксом xn-- метка готова.
Хост режется по точкам, и каждая часть живёт отдельной жизнью. Те, что целиком из ASCII, остаются как есть:
return implode('.', array_map(function (string $label): string {
if ($label === '' || preg_match('/^[\x00-\x7F]*$/', $label)) {
return $label;
}
return 'xn--'.self::encodeLabel($label);
}, $labels));
Адаптация смещения, она же главная магия
Самое хитрое место алгоритма нужно для того, чтобы хвост получался коротким. Порог, по которому кодируются цифры, не константа, а «плавает» и пересчитывается после каждого символа функцией adapt(). Все числа в ней (основание 36, пороги 1 и 26, смещение 38, демпфер 700, начальный bias 72) я списала из RFC один в один. Читается он местами как инструкция к шкафу из ИКЕА: ничего не понятно, но если строго по схеме, оно каким-то чудом собирается.
private static function adapt(int $delta, int $points, bool $first): int
{
$delta = $first ? intdiv($delta, self::DAMP) : intdiv($delta, 2);
$delta += intdiv($delta, $points);
$k = 0;
while ($delta > intdiv((self::BASE - self::TMIN) * self::TMAX, 2)) {
$delta = intdiv($delta, self::BASE - self::TMIN);
$k += self::BASE;
}
return $k + intdiv((self::BASE - self::TMIN + 1) * $delta, $delta + self::SKEW);
}
Кодирование и декодирование ходят через одну и ту же функцию. Поэтому баг в ней ломает оба направления сразу, и для тестов это подарок: не спрячется.
Как декодер защищается от кривого ввода
- Символы вне алфавита punycode вызывают понятную ошибку «Некорректная запись punycode» вместо тихого мусора на выходе.
- Если хвост оборвался посреди числа, декодер останавливается и не читает дальше конца строки.
- Код символа больше U+10FFFF отклоняется как несуществующий.
- Хост приводится к нижнему регистру, пустые части сохраняются.
Чего этот код не умеет
Полную нормализацию Unicode (IDNA) он не делает: только Punycode и нижний регистр. Правил регистраторов по зонам тоже нет. Если intl под рукой, со стандартом целиком он справится лучше, тут без вопросов.
Зато нужный кусок RFC занимает около двухсот строк и одинаково работает на PHP и в браузере. В общем списке случаев для сравнения двух языков у Punycode четыре кейса. А ещё этот класс участвует в проверке чужих адресов, про которую я писала в статье про защиту от SSRF.
Комментарии
Буду рада вашим мыслям, идеям и вопросам!
Давайте обсуждать ♡
Делитесь
своими мыслями
— мне очень
это важно ♡