From 8aeafb2ee27aec1a1ef431a82a052163abcdb8d2 Mon Sep 17 00:00:00 2001 From: Anton Gorev Date: Fri, 4 Sep 2026 16:02:27 +0200 Subject: [PATCH 1/2] =?UTF-8?q?refactor(utf8):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D1=8C=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D0=BE?= =?UTF-8?q?=D0=B4=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D1=83=20(#3826)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Данные на диске -- UTF-8, движок держит текст в UTF-8, переводить при записи нечего. Последним местом, писавшим файл в KOI8-R, оставался craft.cpp: XML-декларация заявляла encoding="koi8-r", и байты уходили через native_text::write_file. Теперь и декларация, и байты -- UTF-8, как у мира, cfg, state и userdata. Следом уходят сами функции: to_disk (вместе с предохранителем внутри неё), перекодирующий write_file и мёртвая from_utf8 (ноль вызовов). write_file_native переименован обратно в write_file -- отдельное имя было нужно, только пока рядом жил перекодирующий двойник. Парольные вызовы переведены на явный to_koi8. Перекодировка там остаётся, но она про формат СОХРАНЁННЫХ хэшей, а не про кодировку диска: хэши посчитаны по кои-восьмым байтам, и сменить это можно только перехэшированием при успешном входе. Под именем to_disk это читалось как граница диска, которой больше нет, и напрашивалось на переиспользование. Заодно поправлены комментарии, ссылавшиеся на исчезнувшую функцию, и шапка native_text.h, где диск всё ещё числился кои-восьмым. Тесты границы записи заменены тестом границы чтения: она пока остаётся -- файл, написанный до миграции, ещё может попасться. Её снятие -- следующий шаг #3826. --- src/administration/accounts.cpp | 7 ++-- src/administration/password.cpp | 16 ++++----- src/engine/db/obj_save.cpp | 6 ++-- src/engine/network/descriptor_data.cpp | 6 ++-- src/gameplay/clans/house.cpp | 6 ++-- src/gameplay/communication/mail.cpp | 2 +- src/gameplay/crafting/craft.cpp | 4 +-- src/gameplay/economics/exchange.cpp | 6 ++-- src/gameplay/mechanics/glory_const.cpp | 2 +- src/gameplay/mechanics/named_stuff.cpp | 2 +- src/utils/native_text.cpp | 48 +------------------------- src/utils/native_text.h | 32 ++++------------- tests/native_text.cpp | 40 +++++++-------------- tests/password.encoding.cpp | 19 +++++----- 14 files changed, 59 insertions(+), 137 deletions(-) diff --git a/src/administration/accounts.cpp b/src/administration/accounts.cpp index 43691268a..00acdc500 100644 --- a/src/administration/accounts.cpp +++ b/src/administration/accounts.cpp @@ -245,10 +245,11 @@ void Account::set_password(const std::string &password) { } bool Account::compare_password(const std::string &password) { - // Тот же приём, что в Password::compare_password: хэш посчитан по дисковым байтам, - // поэтому пароль приводим к дисковой кодировке перед crypt (issue #3681). + // Тот же приём, что в Password::compare_password: хэши посчитаны по кои-восьмым байтам, + // поэтому пароль приводим к той же форме перед crypt. Это формат сохранённых хэшей, + // а не кодировка диска. return CompareParam(this->hash_password, - CRYPT(native_text::to_disk(password).c_str(), this->hash_password.c_str()), true); + CRYPT(native_text::to_koi8(password).c_str(), this->hash_password.c_str()), true); } bool Account::quest_is_available(int id) { diff --git a/src/administration/password.cpp b/src/administration/password.cpp index cf6191ee1..138792188 100644 --- a/src/administration/password.cpp +++ b/src/administration/password.cpp @@ -29,12 +29,12 @@ namespace Password { -// Хэш пароля -- сохранённые данные, и посчитан он когда-то по дисковым байтам (KOI8-R). -// Движок теперь держит текст нативным, поэтому кириллический пароль дал бы другие байты -// и не сошёлся бы с сохранённым хэшем. Приводим к дисковой форме перед crypt: старые хэши -// продолжают сходиться, а новые остаются пригодными для отката (issue #3681). +// Хэш пароля -- сохранённые данные, и посчитан он когда-то по кои-восьмым байтам. Движок +// держит текст в UTF-8, поэтому кириллический пароль дал бы другие байты и не сошёлся бы +// с сохранённым хэшем. Перекодировка здесь -- не граница диска (её больше нет), а формат +// самих сохранённых хэшей: сменить его можно только перехэшированием при успешном входе. static std::string password_bytes(const std::string &pwd) { - return native_text::to_disk(pwd); + return native_text::to_koi8(pwd); } const char *BAD_PASSWORD = "Пароль должен быть от 8 до 50 символов и не должен быть именем персонажа."; @@ -44,9 +44,9 @@ const unsigned int MAX_PWD_LENGTH = 50; // * Генерация хэша с более-менее рандомным сальтом std::string generate_md5_hash(const std::string &pwd) { #ifdef NOCRYPT - // И здесь дисковая форма: сравнение всё равно приводит пароль к ней, а хранить - // нативную значило бы, что кириллический пароль не сойдётся сам с собой. Сборки - // без crypt() -- это Windows, macOS и -Dnocrypt=true (issue #3681). + // И здесь та же форма: сравнение всё равно приводит пароль к ней, а хранить + // UTF-8 значило бы, что кириллический пароль не сойдётся сам с собой. Сборки + // без crypt() -- это Windows, macOS и -Dnocrypt=true. return password_bytes(pwd); #else char key[14]; diff --git a/src/engine/db/obj_save.cpp b/src/engine/db/obj_save.cpp index f28a900bf..e175012e6 100644 --- a/src/engine/db/obj_save.cpp +++ b/src/engine/db/obj_save.cpp @@ -1597,9 +1597,9 @@ int Crash_load(CharData *ch) { // Считается по дисковым байтам -- до перевода в нативную кодировку, иначе сумма не сойдётся. FileCRC::verify_from_content(ch->get_uid(), FileCRC::kTextObjs, readdata, fsize); - // Граница чтения: файл лежит в кодировке мира (сейчас KOI8-R), в память вещи идут - // нативными -- зеркало к to_disk на записи. Без этого имена, алиасы и метки вещей - // уезжают в транслит при первом же сохранении (issue #3681). + // Граница чтения: файл на диске в UTF-8, но написанный до миграции ещё может оказаться + // кои-восьмым. from_disk_text распознаёт то и другое; без него имена, алиасы и метки + // вещей уезжали в транслит при первом же сохранении (issue #3681). { const std::string native = native_text::from_disk_text(std::string(readdata, static_cast(fsize))); free(readdata); diff --git a/src/engine/network/descriptor_data.cpp b/src/engine/network/descriptor_data.cpp index 454ade423..2184a29cd 100644 --- a/src/engine/network/descriptor_data.cpp +++ b/src/engine/network/descriptor_data.cpp @@ -176,9 +176,9 @@ void DescriptorData::string_to_client_encoding(const char *in_str, char *out_str // поэтому перед ними текст надо привести к KOI8-R. Под KOI8-R-рантаймом это тождество, // под UTF-8 - настоящая перекодировка (issue #3681). Для UTF-8-клиента ничего приводить // не нужно: см. case kCodePageUTF8 ниже. - // Зеркало предохранителя из to_disk. Всё нативное -- валидный UTF-8; если сюда пришло - // иное, значит текст прочитан с диска мимо границы и игрок увидит кашу. Так уже уезжали - // экран справки и список синонимов (issue #3681). + // Предохранитель. Всё нативное -- валидный UTF-8; если сюда пришло иное, значит текст + // прочитан с диска мимо границы и игрок увидит кашу. Так уже уезжали экран справки + // и список синонимов (issue #3681). // // Проверка -- полный разбор строки, быстрого выхода на латинице в is_valid нет. Замерено: // 436 нс на строку в 208 байт, около 477 МБ/с. Для легаси-клиентов это заметно дешевле diff --git a/src/gameplay/clans/house.cpp b/src/gameplay/clans/house.cpp index 648d871d4..7c1f58ebd 100644 --- a/src/gameplay/clans/house.cpp +++ b/src/gameplay/clans/house.cpp @@ -2569,9 +2569,9 @@ void Clan::ChestLoad() { } fclose(fl); - // Граница чтения: сундук лежит на диске в кодировке мира, в память идёт нативным -- - // зеркало к to_disk в ChestSaver. Без этого имена и метки вещей в сундуке уезжают - // в транслит при первом же сохранении дружины (issue #3681). + // Граница чтения: сундук на диске в UTF-8, но написанный до миграции файл ещё может + // оказаться кои-восьмым. from_disk_text распознаёт то и другое; без него имена и метки + // вещей в сундуке уезжали в транслит при первом же сохранении дружины (issue #3681). { const std::string native = native_text::from_disk_text(std::string(databuf, static_cast(fsize))); delete[] databuf; diff --git a/src/gameplay/communication/mail.cpp b/src/gameplay/communication/mail.cpp index 827d329dd..325df72cf 100644 --- a/src/gameplay/communication/mail.cpp +++ b/src/gameplay/communication/mail.cpp @@ -594,7 +594,7 @@ void save() { // (read_data_file) принимает и старый KOI8-R (issue #3787). std::ostringstream xml; doc.save(xml, "\t", pugi::format_default, pugi::encoding_utf8); - native_text::write_file_native(MAIL_XML_FILE, xml.str()); + native_text::write_file(MAIL_XML_FILE, xml.str()); need_save = false; } diff --git a/src/gameplay/crafting/craft.cpp b/src/gameplay/crafting/craft.cpp index 72113f64d..5657ea9c4 100644 --- a/src/gameplay/crafting/craft.cpp +++ b/src/gameplay/crafting/craft.cpp @@ -1548,10 +1548,8 @@ bool CCraftModel::export_object(const ObjVnum vnum, const char *filename) { pugi::xml_node decl = document.prepend_child(pugi::node_declaration); decl.append_attribute("version") = "1.0"; - decl.append_attribute("encoding") = "koi8-r"; + decl.append_attribute("encoding") = "utf-8"; - // Граница записи: XML уходит на диск в кодировке мира, а не в нативной. Объявление выше - // так и заявляет koi8-r, значит и байты должны быть koi8-r (issue #3681). std::ostringstream xml; document.save(xml, "\t", pugi::format_default, pugi::encoding_utf8); return native_text::write_file(filename, xml.str()); diff --git a/src/gameplay/economics/exchange.cpp b/src/gameplay/economics/exchange.cpp index 6055a98e1..94b983b3c 100644 --- a/src/gameplay/economics/exchange.cpp +++ b/src/gameplay/economics/exchange.cpp @@ -895,9 +895,9 @@ int LoadExchange() { }; fclose(fl); - // Граница чтения: база лежит на диске в KOI8-R, а движок работает с текстом в нативной - // кодировке. Без этого названия лотов оставались бы байтами чужой кодировки -- и на экране, - // и при обратной записи через to_disk, которая приняла бы их за UTF-8 (issue #3681). + // Граница чтения: база на диске в UTF-8, но файл, написанный до миграции, ещё может + // оказаться кои-восьмым. from_disk_text распознаёт то и другое; без него названия лотов + // остались бы байтами чужой кодировки прямо на экране (issue #3681). const std::string native = native_text::from_disk_text(std::string(raw.data(), actual_size)); CREATE(readdata, native.size() + 1); memcpy(readdata, native.c_str(), native.size() + 1); diff --git a/src/gameplay/mechanics/glory_const.cpp b/src/gameplay/mechanics/glory_const.cpp index 941018b51..44de0ff66 100644 --- a/src/gameplay/mechanics/glory_const.cpp +++ b/src/gameplay/mechanics/glory_const.cpp @@ -899,7 +899,7 @@ void save() { // (read_data_file) принимает и старый KOI8-R (issue #3787). std::ostringstream xml; doc.save(xml, "\t", pugi::format_default, pugi::encoding_utf8); - native_text::write_file_native(LIB_USERDATA"glory_const.xml", xml.str()); + native_text::write_file(LIB_USERDATA"glory_const.xml", xml.str()); } void load() { diff --git a/src/gameplay/mechanics/named_stuff.cpp b/src/gameplay/mechanics/named_stuff.cpp index b29b11f13..1dc5c7fff 100644 --- a/src/gameplay/mechanics/named_stuff.cpp +++ b/src/gameplay/mechanics/named_stuff.cpp @@ -72,7 +72,7 @@ void save() { // (read_data_file) принимает и старый KOI8-R (issue #3787). std::ostringstream xml; doc.save(xml, "\t", pugi::format_default, pugi::encoding_utf8); - native_text::write_file_native(LIB_USERDATA"named_items.xml", xml.str()); + native_text::write_file(LIB_USERDATA"named_items.xml", xml.str()); } bool check_named(CharData *ch, const ObjData *obj, const bool simple) { diff --git a/src/utils/native_text.cpp b/src/utils/native_text.cpp index d8e9cae9e..bd0e01da7 100644 --- a/src/utils/native_text.cpp +++ b/src/utils/native_text.cpp @@ -409,10 +409,6 @@ std::string to_koi8(const std::string &text) { return codepages::Utf8ToKoi8(text); } -std::string from_utf8(const std::string &text) { - return text; // the native encoding already is UTF-8 -} - std::string translit_to_filename(std::string_view name) { // Code point -> the very same Latin character the KOI8-R byte table yields, so a player's // file name is identical before and after the flip. Upper and lower case collapse together @@ -580,17 +576,8 @@ std::size_t char_offset(std::string_view s, std::size_t chars) { return utf8::byte_offset(s, chars); } -bool write_file(const std::string &path, const std::string &text) { - const std::string on_disk = to_disk(text); - std::ofstream out(path, std::ios::binary); - if (!out) { - return false; - } - out.write(on_disk.data(), static_cast(on_disk.size())); - return out.good(); -} -bool write_file_native(const std::string &path, const std::string &text) { +bool write_file(const std::string &path, const std::string &text) { std::ofstream out(path, std::ios::binary); if (!out) { return false; @@ -608,39 +595,6 @@ std::string pad_right(std::string_view s, std::size_t width) { return out; } -std::string to_disk(const std::string &text) { - // Предохранитель. Всё нативное -- валидный UTF-8; если сюда пришло что-то другое, значит - // строка не проходила границу чтения и держит дисковые байты (KOI8-R) как есть. - // Транслитерировать их нельзя: to_koi8 разберёт такие байты как Latin-1 и прогонит через - // словарь замен, а это необратимо -- 'верий.свет' превращается в 'AIEUAxAOA.OxAO'. Именно - // так были съедены метки вещей, сундуки дружин и списки имён (issue #3681). - // - // Поэтому пишем байты как есть -- для диска они уже в нужной кодировке, файл остаётся цел, -- - // и жалуемся в лог: дыру видно сразу, без нагрузочного прогона и без потери данных. - if (!utf8::is_valid(text)) { - static std::atomic seen{0}; - const unsigned long n = seen.fetch_add(1); - if (n < 10 || n % 10000 == 0) { - // Байты печатаются шестнадцатеричными нарочно: сунуть их в сообщение как есть - // значило бы отдать логгеру невалидный UTF-8, а он пишет через этот же to_disk -- - // и жалоба принялась бы жаловаться сама на себя без конца. - std::string head; - const std::size_t show = std::min(text.size(), 16); - char byte[4]; - for (std::size_t i = 0; i < show; ++i) { - std::snprintf(byte, sizeof(byte), "%02x", static_cast(text[i])); - head += byte; - head += ' '; - } - log("SYSERR: to_disk got non-UTF-8 text (#%lu, %zu bytes) -- a read boundary is missing " - "somewhere; writing the bytes through unchanged. First bytes: %s", - n + 1, text.size(), head.c_str()); - } - return text; - } - return to_koi8(text); -} - std::string read_data_file(const std::string &path) { std::ifstream in(path, std::ios::binary); if (!in) { diff --git a/src/utils/native_text.h b/src/utils/native_text.h index b7b427365..1592bac9d 100644 --- a/src/utils/native_text.h +++ b/src/utils/native_text.h @@ -9,8 +9,8 @@ LOWER(*s) or s[0] = UPPER(s[0]) is wrong on a multibyte letter and was the singl of bugs in the migration. The conversions at the bottom of this header are boundaries, not helpers for everyday code: -from_disk_* / to_disk for the world files (still KOI8-R on disk), to_koi8 for legacy client code -pages (their tables are indexed by KOI8-R bytes). +to_koi8 / from_koi8 for legacy client code pages (their tables are indexed by KOI8-R bytes), and +from_disk_* for files written before the UTF-8 migration. */ #ifndef BYLINS_SRC_UTILS_NATIVE_TEXT_H_ @@ -155,11 +155,6 @@ std::string from_koi8(const std::string &text); // equivalent at all becomes the converter's placeholder. std::string to_koi8(const std::string &text); -// Bring text that is UTF-8 on disk into the native encoding. The counterpart of from_koi8 for -// the files that are deliberately kept in UTF-8 rather than KOI8-R (the login screen). Identity -// under UTF-8; under KOI8-R it goes through the same reduction as to_koi8, so a file written -// with the full Unicode repertoire still renders sensibly on a KOI8-R build. -std::string from_utf8(const std::string &text); // Collation key for sorting Russian text. The Russian letters are not in alphabetical order in // KOI8-R, so sorting has always gone through Windows-1251 bytes, where they are. The key @@ -189,26 +184,11 @@ std::string from_disk_line(const char *line); // exactly what happened to cfg/mechanics/obj_sets.xml, issue #3681). std::string from_disk_text(const std::string &text); -// The write side of the same boundary, and the exact mirror of from_disk_text: whatever the -// engine puts on disk goes out in the encoding the disk format is in, which during the migration -// is still KOI8-R. Identity under KOI8-R; under UTF-8 the text is reduced and transcoded exactly -// as it is for a legacy client (see to_koi8). -// -// Read and write MUST stay symmetric. If the engine writes the native encoding while the rest of -// the world is KOI8-R, then the first save quietly converts every file it touches, rolling back -// to a KOI8-R build stops being possible, and the world is no longer the world we started with. -// (issue #3681). -std::string to_disk(const std::string &text); - -// Записать текст в файл в кодировке мира. Однострочная обёртка над to_disk для тех, кто иначе -// звал бы pugi::save_file или свой ofstream и уносил бы на диск нативную кодировку. Возвращает -// false, если файл не открылся (issue #3681). -bool write_file(const std::string &path, const std::string &text); -// То же, но без перевода: для деревьев, которые уже хранятся в нативной кодировке (userdata, -// state, мир). Отдельная функция, а не флаг у write_file, чтобы в месте вызова было видно, в -// какой кодировке лежит файл, и чтобы граница записи читалась рядом с чтением (issue #3787). -bool write_file_native(const std::string &path, const std::string &text); +// Записать текст в файл как есть. Диск и движок в одной кодировке, переводить нечего; +// функция существует ради тех, кто иначе звал бы pugi::save_file или свой ofstream. +// Возвращает false, если файл не открылся. +bool write_file(const std::string &path, const std::string &text); // Pad `s` on the right with spaces to `width` CHARACTERS. The replacement for printf's "%-Ns" // wherever the value can hold Russian: printf counts the field width in bytes, so under UTF-8 a diff --git a/tests/native_text.cpp b/tests/native_text.cpp index 9cc6ccb01..48bc363c9 100644 --- a/tests/native_text.cpp +++ b/tests/native_text.cpp @@ -462,39 +462,25 @@ TEST(NativeText, CharOffsetCutsOnCharacterBoundaries) { EXPECT_EQ(native_text::char_offset("abcdef", 4), 4u); } -TEST(NativeText, ToDiskNeverTransliteratesDiskBytes) { - // Пара к границе чтения. Если строка не проходила from_disk, она держит кои-восьмые байты, - // и старый to_disk разбирал их как Latin-1 и гнал через словарь транслита: 'верий.свет' - // становился 'AIEUAxAOA.OxAO' -- необратимо. Так были съедены метки вещей, сундуки дружин - // и списки имён (issue #3681). Теперь такие байты уходят на диск как есть. +TEST(NativeText, ReadBoundaryTakesBothEncodings) { + // Граница чтения осталась одна: файлы на диске в UTF-8, но старые, написанные до миграции, + // ещё могут попасться. Разделитель -- валидность UTF-8: кириллица в KOI8-R валидным UTF-8 + // почти никогда не бывает. const std::string koi8_bytes = "\xD7\xC5\xD2\xC9\xCA.\xD3\xD7\xC5\xD4"; // 'верий.свет' в KOI8-R - EXPECT_EQ(native_text::to_disk(koi8_bytes), koi8_bytes) << "дисковые байты не должны меняться"; - - // А нативный текст по-прежнему переводится в кодировку диска. const std::string native = native_text::from_koi8(koi8_bytes); - EXPECT_NE(native, koi8_bytes) << "проверка построена на том, что кодировки различаются"; - EXPECT_EQ(native_text::to_disk(native), koi8_bytes); - - // Чистая латиница одинакова в обеих кодировках и не трогается ни в одном из случаев. - EXPECT_EQ(native_text::to_disk("plain ascii"), "plain ascii"); -} - -TEST(NativeText, DiskRoundTripIsByteIdentical) { - // Правило пары: что прочитано через границу, должно уйти обратно теми же байтами. - // Нарушение этой пары -- одностороннее чтение или одностороння запись -- и съело - // метки вещей, сундуки дружин и списки имён (issue #3681). - const std::string on_disk = "\xD7\xC5\xD2\xC9\xCA.\xD3\xD7\xC5\xD4"; // 'верий.свет' в KOI8-R + ASSERT_NE(native, koi8_bytes) << "проверка построена на том, что кодировки различаются"; - const std::string native = native_text::from_disk_text(on_disk); - EXPECT_EQ(native_text::to_disk(native), on_disk) << "чтение и запись обязаны быть зеркальны"; + EXPECT_EQ(native_text::from_disk_text(koi8_bytes), native) << "старый файл читается как KOI8-R"; + EXPECT_EQ(native_text::from_disk_text(native), native) << "нативный текст не трогается"; - // Повторное чтение уже нативного текста ничего не меняет: именно на этом держится - // идемпотентность цикла загрузка-сохранение. - EXPECT_EQ(native_text::from_disk_text(native), native); + // Идемпотентность: повторное чтение уже прочитанного ничего не меняет. На этом держится + // цикл загрузка-сохранение -- без неё кириллица удваивалась бы на каждом обороте + // (так и разросся cfg/mechanics/obj_sets.xml, issue #3681). + EXPECT_EQ(native_text::from_disk_text(native_text::from_disk_text(koi8_bytes)), native); - // И то же самое для чистой латиницы -- она одинакова в обеих кодировках. + // Чистая латиница одинакова в обеих кодировках. const std::string ascii = "plain ascii line"; - EXPECT_EQ(native_text::to_disk(native_text::from_disk_text(ascii)), ascii); + EXPECT_EQ(native_text::from_disk_text(ascii), ascii); } // vim: ts=4 sw=4 tw=0 noet syntax=cpp : diff --git a/tests/password.encoding.cpp b/tests/password.encoding.cpp index 0b321c721..bea484eb4 100644 --- a/tests/password.encoding.cpp +++ b/tests/password.encoding.cpp @@ -34,21 +34,24 @@ TEST(PasswordEncoding, WrongCyrillicPasswordIsRejected) { EXPECT_FALSE(Password::compare_password(hash, other)); } -TEST(PasswordEncoding, HashIsTakenOverTheOnDiskForm) { - // Ключевое свойство. Два разных набора байт в памяти, у которых ОДНА дисковая форма: - // длинное тире на диске становится обычным дефисом (в KOI8-R его попросту нет, см. - // словарь замен). Если хэш считать по байтам памяти, эти пароли разойдутся; если по - // дисковой форме -- совпадут. Именно на этом расхождении и отвалился вход. +TEST(PasswordEncoding, HashIsTakenOverTheLegacyForm) { + // Ключевое свойство. Два разных набора байт в памяти, у которых ОДНА кои-восьмая форма: + // длинное тире становится обычным дефисом (в KOI8-R его попросту нет, см. словарь замен). + // Если хэш считать по байтам памяти, эти пароли разойдутся; если по кои-восьмой форме -- + // совпадут. Именно на этом расхождении и отвалился вход. + // + // Диск тут ни при чём: он давно в UTF-8. Перекодировка осталась потому, что в этой форме + // посчитаны СОХРАНЁННЫЕ хэши, и сменить её можно только перехэшированием при входе. const std::string with_em_dash = "pa\xE2\x80\x94rol"; // pa—rol const std::string with_hyphen = "pa-rol"; ASSERT_NE(with_em_dash, with_hyphen) << "проверка построена на том, что в памяти они разные"; - ASSERT_EQ(native_text::to_disk(with_em_dash), native_text::to_disk(with_hyphen)) - << "...а на диске -- одинаковые"; + ASSERT_EQ(native_text::to_koi8(with_em_dash), native_text::to_koi8(with_hyphen)) + << "...а в кои-восьмой форме -- одинаковые"; const std::string hash = Password::generate_md5_hash(with_em_dash); EXPECT_TRUE(Password::compare_password(hash, with_hyphen)) - << "хэш обязан считаться по дисковой форме, иначе старые хэши не сойдутся"; + << "хэш обязан считаться по кои-восьмой форме, иначе старые хэши не сойдутся"; } TEST(PasswordEncoding, AsciiIsUnaffected) { From ada718720125822de770d1116b20b1ad93c10585 Mon Sep 17 00:00:00 2001 From: stribog Date: Sat, 19 Sep 2026 19:30:58 +0200 Subject: [PATCH 2/2] fix(utf8): keep the write-side guard, drop a dead koi8 declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вместе с to_disk уходил её предохранитель -- жалоба в лог, когда на диск идут не-UTF-8 байты. Граница чтения при этом остаётся, и в комментариях этой же ветки сказано, что файл, написанный до миграции, ещё может попасться. Значит состояние, от которого предохранитель защищал, всё ещё возможно, а сигнала о нём не было бы: кои-восьмые байты уехали бы в файл молча. Проверка переехала в write_file. glory_const писал в документ атрибут encoding="koi8-r", но у xml_document атрибутов нет: в готовом файле стоит просто . Строка мёртвая, а ветка как раз убирает последнее объявление koi8-r в записи. Заодно в ветку влит master: она отстала на две с лишним сотни коммитов. Co-Authored-By: Claude Opus 5 (1M context) --- src/gameplay/mechanics/glory_const.cpp | 1 - src/utils/native_text.cpp | 25 +++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/gameplay/mechanics/glory_const.cpp b/src/gameplay/mechanics/glory_const.cpp index 3fa1bf276..ca97d9a16 100644 --- a/src/gameplay/mechanics/glory_const.cpp +++ b/src/gameplay/mechanics/glory_const.cpp @@ -857,7 +857,6 @@ void do_glory(CharData *ch, char *argument, int/* cmd*/, int/* subcmd*/) { void save() { pugi::xml_document doc; - doc.append_attribute("encoding") = "koi8-r"; doc.append_child().set_name("glory_list"); pugi::xml_node char_list = doc.child("glory_list"); diff --git a/src/utils/native_text.cpp b/src/utils/native_text.cpp index f34e84405..252ddc9cc 100644 --- a/src/utils/native_text.cpp +++ b/src/utils/native_text.cpp @@ -542,6 +542,31 @@ std::size_t char_offset(std::string_view s, std::size_t chars) { bool write_file(const std::string &path, const std::string &text) { + // Предохранитель, переехавший сюда из to_disk. Всё нативное -- валидный UTF-8; если + // сюда пришло иное, значит строка не проходила границу чтения и держит дисковые байты + // (KOI8-R) как есть. Пишем их всё равно -- файл остаётся цел, -- но жалуемся в лог: + // граница чтения ещё нужна, файл до миграции всё ещё может попасться, а без этой + // жалобы пропущенная граница уезжает на диск молча. + if (!utf8::is_valid(text)) { + static std::atomic seen{0}; + const unsigned long n = seen.fetch_add(1); + if (n < 10 || n % 10000 == 0) { + // Байты печатаются шестнадцатеричными нарочно: невалидный UTF-8 в сообщении + // логгер погнал бы обратно через эту же запись. + std::string head; + const std::size_t show = std::min(text.size(), 16); + char byte[4]; + for (std::size_t i = 0; i < show; ++i) { + std::snprintf(byte, sizeof(byte), "%02x", static_cast(text[i])); + head += byte; + head += ' '; + } + log("SYSERR: write_file got non-UTF-8 text (#%lu, %zu bytes, %s) -- a read boundary " + "is missing somewhere; writing the bytes through unchanged. First bytes: %s", + n + 1, text.size(), path.c_str(), head.c_str()); + } + } + std::ofstream out(path, std::ios::binary); if (!out) { return false;