Punycode на PHP без расширения intl

0

Домены ♡

Браузер вежливо показывает мерка.рф, а 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);
}

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

Как декодер защищается от кривого ввода

  1. Символы вне алфавита punycode вызывают понятную ошибку «Некорректная запись punycode» вместо тихого мусора на выходе.
  2. Если хвост оборвался посреди числа, декодер останавливается и не читает дальше конца строки.
  3. Код символа больше U+10FFFF отклоняется как несуществующий.
  4. Хост приводится к нижнему регистру, пустые части сохраняются.

Чего этот код не умеет

Полную нормализацию Unicode (IDNA) он не делает: только Punycode и нижний регистр. Правил регистраторов по зонам тоже нет. Если intl под рукой, со стандартом целиком он справится лучше, тут без вопросов.

Зато нужный кусок RFC занимает около двухсот строк и одинаково работает на PHP и в браузере. В общем списке случаев для сравнения двух языков у Punycode четыре кейса. А ещё этот класс участвует в проверке чужих адресов, про которую я писала в статье про защиту от SSRF.

Комментарии

Буду рада вашим мыслям, идеям и вопросам!
Давайте обсуждать ♡

Пока никто не написал.
Станьте первым! ♡