From 13918d1ceaea2e0eca4ca9c8e528f386fb64d1dc Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 02:57:07 +0100 Subject: [PATCH] Preserve desktop configuration on failed writes --- src/config_ini.h | 2 +- tools/chatgui/CMakeLists.txt | 2 +- tools/chatgui/tests/test_config_updater.cpp | 259 +++++++++++-------- tools/chatgui/transport/config_updater.cpp | 266 ++++++++++---------- tools/chatgui/transport/config_updater.h | 30 ++- 5 files changed, 317 insertions(+), 242 deletions(-) diff --git a/src/config_ini.h b/src/config_ini.h index fe4c5c67..7a573119 100644 --- a/src/config_ini.h +++ b/src/config_ini.h @@ -80,7 +80,7 @@ static inline int config_ini_read(FILE *file, const char *filename, char **text, *text = NULL; *length = 0; while ((count = fread(chunk, 1, sizeof(chunk), file)) != 0) { - const char *nul = memchr(chunk, '\0', count); + const char *nul = (const char *)memchr(chunk, '\0', count); if (nul) { DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "%s: embedded NUL at byte %zu", filename, output.len + (size_t)(nul - chunk)); errno = EINVAL; diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 7e0daedd..fe3efa9e 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -594,7 +594,7 @@ add_executable(test_config_updater transport/config_updater.cpp ) target_include_directories(test_config_updater PRIVATE ${CMAKE_SOURCE_DIR}) -target_link_libraries(test_config_updater PRIVATE ${QT_CORE} pthread) +target_link_libraries(test_config_updater PRIVATE ${QT_CORE} ${QT_TEST_LIB} utun pthread) add_test(NAME test_config_updater COMMAND test_config_updater) add_executable(test_node_config tests/test_node_config.cpp transport/node_config.cpp transport/config_updater.cpp) diff --git a/tools/chatgui/tests/test_config_updater.cpp b/tools/chatgui/tests/test_config_updater.cpp index cbdcc2e0..d95d7f38 100644 --- a/tools/chatgui/tests/test_config_updater.cpp +++ b/tools/chatgui/tests/test_config_updater.cpp @@ -1,117 +1,170 @@ -// tests/test_config_updater.cpp — Verify universal config updater +/* Обновление настоящих INI: дубли, UTF-8, независимые секции и отказ записи. */ #include "../transport/config_updater.h" +#include +#include #include -#include - -#define VERIFY(cond, msg) do { if (!(cond)) { qCritical("FAIL: %s", msg); abort(); } } while(0) - -static void test_new_file() { - const char* path = "/tmp/test_cfg_new.ini"; - QFile::remove(path); - - VERIFY(config_update_option(path, "global", "my_name", "testnode"), "new file write"); - - QFile f(path); - VERIFY(f.open(QIODevice::ReadOnly | QIODevice::Text), "new file open for read"); - QString content = f.readAll(); - f.close(); - VERIFY(content.contains("[global]"), "new file has [global]"); - VERIFY(content.contains("my_name=testnode"), "new file has my_name"); - QFile::remove(path); +extern "C" { +#include "debug_config.h" } +#ifdef Q_OS_UNIX +#include +#include +#endif -static void test_replace_existing() { - const char* path = "/tmp/test_cfg_replace.ini"; - { - QFile f(path); - VERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text), "replace: create file"); - f.write("[global]\nmy_name=oldname\nmy_id=abc\n\n[control]\naddr=1.2.3.4\n"); - f.close(); +class ConfigUpdaterTest : public QObject { + Q_OBJECT + QTemporaryDir directory; + + QString path() const { return directory.filePath("node.cfg"); } + bool put(const QByteArray& data) { + QFile file(path()); + return file.open(QIODevice::WriteOnly) && file.write(data) == data.size() && file.flush(); + } + QByteArray contents() const { + QFile file(path()); + if (!file.open(QIODevice::ReadOnly)) return {}; + return file.readAll(); + } +private slots: + void initTestCase() { + debug_config_init(); + debug_enable_console(1); + debug_set_category_level(DEBUG_CATEGORY_CONFIG, DEBUG_LEVEL_DEBUG); + } + void init() { QVERIFY(directory.isValid()); QFile::remove(path()); } + + void newFileAndSection() { + QVERIFY(config_update_option(path(), "global", "my_node_name", QString::fromUtf8("Тест"))); + QVERIFY(config_update_option(path(), "gui", "language", "ru")); + QCOMPARE(contents(), QByteArray("[global]\nmy_node_name=") + QString::fromUtf8("Тест").toUtf8() + + "\n\n[gui]\nlanguage=ru\n"); } - VERIFY(config_update_option(path, "global", "my_name", "newname"), "replace: update"); - - QFile f(path); - VERIFY(f.open(QIODevice::ReadOnly | QIODevice::Text), "replace: open for read"); - QString content = f.readAll(); - f.close(); - VERIFY(!content.contains("my_name=oldname"), "replace: old value gone"); - VERIFY(content.contains("my_name=newname"), "replace: new value present"); - VERIFY(content.contains("my_id=abc"), "replace: my_id preserved"); - VERIFY(content.contains("[control]"), "replace: [control] preserved"); - VERIFY(content.contains("addr=1.2.3.4"), "replace: addr preserved"); - QFile::remove(path); -} - -static void test_replace_new_param_in_section() { - const char* path = "/tmp/test_cfg_newparam.ini"; - { - QFile f(path); - VERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text), "newparam: create file"); - f.write("[global]\nmy_name=test\n\n[control]\naddr=1.2.3.4\n"); - f.close(); + void replacementUpdatesAllDuplicates() { + QVERIFY(put("# keep\n[global]\nmy_node_name=old\nmy_node_name=last\n" + "[control]\nport=9\n[GLOBAL]\nmy_node_name=override\n")); + QVERIFY(config_update_option(path(), "global", "my_node_name", "new")); + QCOMPARE(contents().count("my_node_name=new"), 3); + QVERIFY(!contents().contains("old")); + QVERIFY(!contents().contains("override")); + QVERIFY(contents().contains("# keep\n")); + QVERIFY(contents().contains("[control]\nport=9\n")); } - VERIFY(config_update_option(path, "global", "new_param", "newval"), "newparam: insert"); - - QFile f(path); - VERIFY(f.open(QIODevice::ReadOnly | QIODevice::Text), "newparam: open for read"); - QString content = f.readAll(); - f.close(); - VERIFY(content.contains("my_name=test"), "newparam: my_name preserved"); - VERIFY(content.contains("new_param=newval"), "newparam: new_param inserted"); - VERIFY(content.contains("[control]"), "newparam: [control] preserved"); - QFile::remove(path); -} - -static void test_append_mode() { - const char* path = "/tmp/test_cfg_append.ini"; - { - QFile f(path); - VERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text), "append: create file"); - f.write("[client:peer1]\nlink=srv:addr1:port1\nlink=srv:addr2:port2\n\n[global]\nname=x\n"); - f.close(); + void chatAliasUsesSameSection() { + QVERIFY(put("[ chat ]\nradio_auto_enabled=0\n[chatserver]\nradio_auto_enabled=0\n")); + QVERIFY(config_update_option(path(), "chatserver", "radio_auto_enabled", "1")); + QCOMPARE(contents().count("radio_auto_enabled=1"), 2); } - VERIFY(config_update_option(path, "client:peer1", "link", "srv:addr3:port3", CONFIG_UPDATE_APPEND), "append: add link"); - - QFile f(path); - VERIFY(f.open(QIODevice::ReadOnly | QIODevice::Text), "append: open for read"); - QString content = f.readAll(); - f.close(); - VERIFY(content.contains("link=srv:addr1:port1"), "append: link1 preserved"); - VERIFY(content.contains("link=srv:addr2:port2"), "append: link2 preserved"); - VERIFY(content.contains("link=srv:addr3:port3"), "append: link3 added"); - QFile::remove(path); -} - -static void test_new_section() { - const char* path = "/tmp/test_cfg_newsect.ini"; - { - QFile f(path); - VERIFY(f.open(QIODevice::WriteOnly | QIODevice::Text), "newsect: create file"); - f.write("[global]\nname=x\n"); - f.close(); + void appendUsesLastMatchingSection() { + QStringList lines{"[client:peer]", "link=one", "[client:other]", "link=other", + "[client:peer]", "link=two", "[global]", "my_node_name=keep"}; + QVERIFY(config_update_in_lines(lines, "client:peer", "link", "three", false)); + QCOMPARE(lines.indexOf("link=three"), lines.indexOf("link=two") + 1); + QCOMPARE(lines.count("link=one"), 1); + QCOMPARE(lines.count("link=other"), 1); } - VERIFY(config_update_option(path, "gui", "theme", "dark"), "newsect: add [gui]"); + void insertsMissingKey() { + QStringList lines{"[global]", "my_node_name=keep", "[gui]", "language=en"}; + QVERIFY(config_update_in_lines(lines, "global", "auto_sockets", "yes", true)); + QCOMPARE(lines, (QStringList{"[global]", "auto_sockets=yes", "my_node_name=keep", "[gui]", "language=en"})); + } - QFile f(path); - VERIFY(f.open(QIODevice::ReadOnly | QIODevice::Text), "newsect: open for read"); - QString content = f.readAll(); - f.close(); - VERIFY(content.contains("[global]"), "newsect: [global] preserved"); - VERIFY(content.contains("name=x"), "newsect: name preserved"); - VERIFY(content.contains("[gui]"), "newsect: [gui] added"); - VERIFY(content.contains("theme=dark"), "newsect: theme added"); - QFile::remove(path); -} + void invalidArgumentsDoNotMutate() { + const QStringList original{"[global]", "my_node_name=keep"}; + for (const auto& values : {QStringList{"", "name", "value"}, QStringList{"global", "x=y", "value"}, + QStringList{"global", "name", "one\ntwo"}, QStringList{"global]", "name", "value"}}) { + auto lines = original; + QVERIFY(!config_update_in_lines(lines, values[0], values[1], values[2], true)); + QCOMPARE(lines, original); + } + } -int main() { - test_new_file(); - test_replace_existing(); - test_replace_new_param_in_section(); - test_append_mode(); - test_new_section(); - return 0; -} + void rewritesOnlyServerSections() { + QVERIFY(put("[global]\nmy_node_name=keep\n\n[ SERVER : old ]\naddr=127.0.0.1:1\n" + "[client:peer]\nlink=old:127.0.0.1:2\n[server:other]\naddr=[::1]:3\n[gui]\nlanguage=ru\n")); + QVERIFY(config_rewrite_servers(path(), {{"lan", "127.0.0.1:10", "", "", ""}, + {"lan6", "[::1]:10", "tcp", "local", "eth0"}})); + const auto text = contents(); + QVERIFY(!text.contains("[ SERVER : old ]")); + QVERIFY(!text.contains("[server:other]")); + QVERIFY(text.contains("[server:lan]\naddr=127.0.0.1:10\ntransport=udp\ntype=public\n")); + QVERIFY(text.contains("[server:lan6]\naddr=[::1]:10\ntransport=tcp\ntype=local\nnetif=eth0\n")); + QVERIFY(text.indexOf("[server:lan]") < text.indexOf("[client:peer]")); + QVERIFY(text.contains("[client:peer]\nlink=old:127.0.0.1:2\n[gui]\nlanguage=ru\n")); + QVERIFY(config_rewrite_servers(path(), {})); + QVERIFY(!contents().contains("[server:")); + } + + void readAndOpenFailures() { + QVERIFY(!config_update_option(directory.path(), "gui", "language", "en")); + QVERIFY(!config_rewrite_servers(directory.path(), {})); + QVERIFY(!config_update_option(directory.filePath("missing/node.cfg"), "gui", "language", "en")); + QVERIFY(!QFile::exists(directory.filePath("missing/node.cfg"))); + } + + void invalidTextIsNotRewritten() { + for (const QByteArray& original : {QByteArray("[global]\nname=bad\0tail\n", 23), + QByteArray("[global]\nname=\xff\n"), QByteArray("[global]\nname=") + QByteArray(1023, 'x') + '\n'}) { + QVERIFY(put(original)); + QVERIFY(!config_update_option(path(), "global", "my_node_name", "new")); + QCOMPARE(contents(), original); + QVERIFY(!config_rewrite_servers(path(), {})); + QCOMPARE(contents(), original); + } + } + + void invalidServerDoesNotReplaceFile() { + const QByteArray original("[server:keep]\naddr=127.0.0.1:1\n"); + QVERIFY(put(original)); + QVERIFY(!config_rewrite_servers(path(), {{"bad]\n[global", "127.0.0.1:2", "udp", "public", ""}})); + QCOMPARE(contents(), original); + } + + void normalizesCrLf() { + QVERIFY(put("[global]\r\nmy_node_name=old\r\n# keep\r\n")); + QVERIFY(config_update_option(path(), "global", "my_node_name", "new")); + QCOMPARE(contents(), QByteArray("[global]\nmy_node_name=new\n# keep\n")); + } + + void failedWritePreservesOriginal_data() { + QTest::addColumn("servers"); + QTest::newRow("option") << false; + QTest::newRow("servers") << true; + } + void failedWritePreservesOriginal() { +#ifdef Q_OS_UNIX + QFETCH(bool, servers); + const QByteArray original("[global]\nmy_node_name=keep\n"); + QVERIFY(put(original)); + bool saved; + { + struct LimitGuard { + struct rlimit previous{}; + using Handler = void (*)(int); + Handler handler = SIG_DFL; + bool active = false; + ~LimitGuard() { if (active) { setrlimit(RLIMIT_FSIZE, &previous); std::signal(SIGXFSZ, handler); } } + } guard; + QVERIFY(getrlimit(RLIMIT_FSIZE, &guard.previous) == 0); + struct rlimit limited = guard.previous; + limited.rlim_cur = 64; + guard.handler = std::signal(SIGXFSZ, SIG_IGN); + guard.active = true; + QVERIFY(setrlimit(RLIMIT_FSIZE, &limited) == 0); + saved = servers ? config_rewrite_servers(path(), {{"lan", "127.0.0.1:10", "udp", "public", QString(200, 'x')}}) : + config_update_option(path(), "global", "my_node_name", QString(200, 'x')); + } + QVERIFY(!saved); + QCOMPARE(contents(), original); + QCOMPARE(QDir(directory.path()).entryList(QDir::Files | QDir::Hidden | QDir::NoDotAndDotDot), QStringList{"node.cfg"}); +#else + QSKIP("RLIMIT_FSIZE is available on Unix"); +#endif + } +}; + +QTEST_GUILESS_MAIN(ConfigUpdaterTest) +#include "test_config_updater.moc" diff --git a/tools/chatgui/transport/config_updater.cpp b/tools/chatgui/transport/config_updater.cpp index bce8c062..5d4056ed 100644 --- a/tools/chatgui/transport/config_updater.cpp +++ b/tools/chatgui/transport/config_updater.cpp @@ -1,159 +1,165 @@ -// config_updater.cpp — Universal INI config file updater (like insert_or_replace_option in utun) +/* Текстовые INI-обновления без перезаписи исходного файла до успешного commit. */ #include "config_updater.h" #include -#include +#include +#include -static std::pair find_section_bounds(const QStringList& lines, const QString& section) { - QString tag = "[" + section + "]"; - for (int i = 0; i < lines.size(); i++) { - if (lines[i].trimmed() == tag) { - int se = lines.size(); - for (int j = i + 1; j < lines.size(); j++) { - QString t = lines[j].trimmed(); - if (t.startsWith('[') && t.endsWith(']')) { se = j; break; } - } - return {i, se}; - } - } - return {-1, -1}; +extern "C" { +#include "../../../src/config_ini.h" } +/* Тип секции регистронезависим, имя экземпляра сохраняет регистр, как в ядре. */ +static QString normalized_section(const QString& section) { + const int colon = section.indexOf(':'); + if (colon >= 0) return section.left(colon).trimmed().toLower() + ':' + section.mid(colon + 1).trimmed(); + const QString name = section.trimmed().toLower(); + return name == "chat" ? QString("chatserver") : name; +} + +static QString section_name(const QString& line) { + const QString text = line.trimmed(); + return text.startsWith('[') && text.endsWith(']') ? normalized_section(text.mid(1, text.size() - 2)) : QString(); +} + +static bool single_line(const QString& value) { + return !value.contains('\n') && !value.contains('\r') && !value.contains(QChar(0)); +} + +/* Отказ до изменения lines: аргументы не могут создавать дополнительные строки/секции. */ bool config_update_in_lines(QStringList& lines, const QString& section, const QString& key, const QString& value, bool replace) { - auto bounds = find_section_bounds(lines, section); - if (bounds.first < 0) { - if (!lines.isEmpty() && !lines.last().trimmed().isEmpty()) - lines.append(""); - lines.append("[" + section + "]"); - lines.append(key + "=" + value); - return true; + if (section.trimmed().isEmpty() || section != section.trimmed() || !single_line(section) || + section.contains('[') || section.contains(']') || key.trimmed().isEmpty() || key != key.trimmed() || + !single_line(key) || key.contains('=') || key.contains('[') || key.contains(']') || key.startsWith('#') || + key.startsWith(';') || !single_line(value)) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config update rejected invalid section/key or multiline value"); + return false; } - - int si = bounds.first, se = bounds.second; - int ki = -1; - for (int i = si + 1; i < se; i++) { - int eq = lines[i].indexOf('='); - if (eq > 0 && lines[i].left(eq).trimmed() == key) { ki = i; break; } + const QString wanted = normalized_section(section); + const QString replacement = key + '=' + value; + int lastSection = -1, lastKey = -1, replaced = 0; + bool matching = false; + for (int i = 0; i < lines.size(); ++i) { + const QString name = section_name(lines[i]); + if (!name.isEmpty()) { + matching = name == wanted; + if (matching) lastSection = i; + continue; + } + if (!matching) continue; + const int equal = lines[i].indexOf('='); + if (equal <= 0 || lines[i].left(equal).trimmed() != key) continue; + lastKey = i; + if (replace) { lines[i] = replacement; ++replaced; } } - - QString newLine = key + "=" + value; - - if (replace) { - if (ki >= 0) - lines[ki] = newLine; - else - lines.insert(si + 1, newLine); - } else { - if (ki >= 0) { - int lastKi = ki; - for (int i = ki + 1; i < se; i++) { - int eq = lines[i].indexOf('='); - if (eq > 0 && lines[i].left(eq).trimmed() == key) lastKi = i; - } - lines.insert(lastKi + 1, newLine); + if (!replaced) { + if (lastSection < 0) { + if (!lines.isEmpty() && !lines.last().trimmed().isEmpty()) lines.append(""); + lines.append('[' + section + ']'); + lines.append(replacement); } else { - lines.insert(si + 1, newLine); + lines.insert(lastKey >= 0 ? lastKey + 1 : lastSection + 1, replacement); } } + DEBUG_DEBUG(DEBUG_CATEGORY_CONFIG, "GUI config prepared section=%s key=%s mode=%s replaced=%d", + qPrintable(section), qPrintable(key), replace ? "replace" : "append", replaced); + return true; +} + +/* ENOENT допускает создание; ошибки чтения и непредставимый UTF-8 запрещают перезапись. */ +static bool read_config_lines(const QString& path, QStringList& lines) { + QFile input(path); + const QFileInfo info(path); + if (!info.exists() && !info.isSymLink()) return true; + if (!input.open(QIODevice::ReadOnly)) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config read open failed path=%s: %s", qPrintable(path), qPrintable(input.errorString())); + return false; + } + const QByteArray data = input.readAll(); + if (input.error() != QFileDevice::NoError) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config read failed path=%s: %s", qPrintable(path), qPrintable(input.errorString())); + return false; + } + const QString text = QString::fromUtf8(data); + if (text.toUtf8() != data || config_ini_validate(data.constData(), size_t(data.size()), qPrintable(path))) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config rejected invalid UTF-8/INI text path=%s", qPrintable(path)); + return false; + } + if (!text.isEmpty()) { + lines = text.split('\n'); + if (lines.last().isEmpty()) lines.removeLast(); + for (QString& line : lines) if (line.endsWith('\r')) line.chop(1); + } + DEBUG_DEBUG(DEBUG_CATEGORY_CONFIG, "GUI config read path=%s bytes=%lld lines=%lld", + qPrintable(path), (long long)data.size(), (long long)lines.size()); + return true; +} + +/* QSaveFile владеет соседним temp и удаляет его при ошибке; прямой fallback запрещён. */ +static bool write_config_lines(const QString& path, const QStringList& lines) { + QByteArray data = lines.join('\n').toUtf8(); + if (!data.isEmpty() && !data.endsWith('\n')) data.append('\n'); + if (config_ini_validate(data.constData(), size_t(data.size()), qPrintable(path))) return false; + QSaveFile output(path); + output.setDirectWriteFallback(false); + if (!output.open(QIODevice::WriteOnly) || output.write(data) != data.size() || !output.commit()) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config save failed path=%s bytes=%lld: %s", + qPrintable(path), (long long)data.size(), qPrintable(output.errorString())); + return false; + } + DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "GUI config saved path=%s bytes=%lld", qPrintable(path), (long long)data.size()); return true; } bool config_update_option(const QString& filePath, const QString& section, const QString& param, const QString& value, int flags) { - QFile f(filePath); - QStringList lines; - - if (f.exists()) { - if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) return false; - QTextStream in(&f); - while (!in.atEnd()) { - QString line = in.readLine(0); - if (line.endsWith('\r')) line.chop(1); - lines.append(line); - } - f.close(); - } - - bool replace = (flags != CONFIG_UPDATE_APPEND); - if (!config_update_in_lines(lines, section, param, value, replace)) + if (flags != CONFIG_UPDATE_REPLACE && flags != CONFIG_UPDATE_APPEND) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config update rejected flags=%d path=%s", flags, qPrintable(filePath)); return false; - - if (!f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) return false; - QTextStream out(&f); - for (int i = 0; i < lines.size(); i++) - out << lines[i] << '\n'; - f.close(); - return true; -} - -static bool is_section_header(const QString& line) { - QString t = line.trimmed(); - return t.startsWith('[') && t.endsWith(']'); + } + QStringList lines; + if (!read_config_lines(filePath, lines) || + !config_update_in_lines(lines, section, param, value, flags == CONFIG_UPDATE_REPLACE)) return false; + return write_config_lines(filePath, lines); } bool config_rewrite_servers(const QString& filePath, const QList& servers) { - QFile f(filePath); QStringList lines; + if (!read_config_lines(filePath, lines)) return false; - if (f.exists()) { - if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) return false; - QTextStream in(&f); - while (!in.atEnd()) { - QString line = in.readLine(0); - if (line.endsWith('\r')) line.chop(1); - lines.append(line); - } - f.close(); - } - - /* 1. Удалить все [server:*] секции (заголовок + ключи + хвостовую пустую строку-разделитель) */ - QStringList filtered; - filtered.reserve(lines.size()); - for (int i = 0; i < lines.size(); i++) { - QString t = lines[i].trimmed(); - if (t.startsWith("[server:") && t.endsWith(']')) { - int j = i + 1; - while (j < lines.size() && !is_section_header(lines[j])) j++; - i = j - 1; /* for-loop сделает i++ → j: заголовок+ключи+пустая строка пропущены */ - continue; - } - filtered << lines[i]; - } - lines = filtered; - - /* 2. Собрать блок секций [server:*] */ QStringList block; - for (const ConfigServer& s : servers) { - block << ("[server:" + s.name + "]"); - block << ("addr=" + s.addr); - block << ("transport=" + (s.transport.isEmpty() ? QString("udp") : s.transport)); - block << ("type=" + (s.type.isEmpty() ? QString("public") : s.type)); - if (!s.netif.isEmpty()) block << ("netif=" + s.netif); - block << ""; /* разделитель: одна пустая строка между секциями */ - } - - /* 3. Точка вставки: после секции [global] (перед следующей секцией) */ - int insertPos = lines.size(); - for (int i = 0; i < lines.size(); i++) { - if (lines[i].trimmed() == "[global]") { - int j = i + 1; - while (j < lines.size() && !is_section_header(lines[j])) j++; - insertPos = j; - break; + for (const ConfigServer& server : servers) { + if (server.name.trimmed().isEmpty() || server.name != server.name.trimmed() || server.name.contains(':') || + server.name.contains('[') || server.name.contains(']') || !single_line(server.name) || + !single_line(server.addr) || !single_line(server.transport) || + !single_line(server.type) || !single_line(server.netif)) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config rejected malformed server definition path=%s", qPrintable(filePath)); + return false; } + block << ("[server:" + server.name + ']') << ("addr=" + server.addr); + block << ("transport=" + (server.transport.isEmpty() ? QString("udp") : server.transport)); + block << ("type=" + (server.type.isEmpty() ? QString("public") : server.type)); + if (!server.netif.isEmpty()) block << ("netif=" + server.netif); + block << ""; } - /* 4. Вставить блок (если непустой) */ - if (!block.isEmpty()) { - for (int i = block.size() - 1; i >= 0; i--) - lines.insert(insertPos, block[i]); + // Удаляем только server-секции целиком, сохраняя прочие строки в прежнем порядке. + QStringList filtered; + bool serverSection = false; + for (const QString& line : lines) { + const QString section = section_name(line); + if (!section.isEmpty()) serverSection = section.startsWith("server:"); + if (!serverSection) filtered << line; } - - /* 5. Записать */ - if (!f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) return false; - QTextStream out(&f); - for (const QString& l : lines) - out << l << '\n'; - f.close(); - return true; + int insertPos = filtered.size(); + bool globalSection = false; + for (int i = 0; i < filtered.size(); ++i) { + const QString section = section_name(filtered[i]); + if (section.isEmpty()) continue; + if (globalSection) { insertPos = i; break; } + globalSection = section == "global"; + } + for (int i = block.size() - 1; i >= 0; --i) filtered.insert(insertPos, block[i]); + DEBUG_DEBUG(DEBUG_CATEGORY_CONFIG, "GUI config prepared servers path=%s count=%lld", qPrintable(filePath), (long long)servers.size()); + return write_config_lines(filePath, filtered); } diff --git a/tools/chatgui/transport/config_updater.h b/tools/chatgui/transport/config_updater.h index 867b68da..bf16c808 100644 --- a/tools/chatgui/transport/config_updater.h +++ b/tools/chatgui/transport/config_updater.h @@ -1,4 +1,9 @@ -// config_updater.h — Universal INI config file updater (like insert_or_replace_option in utun) +/* Текстовые правки UTF-8 INI для desktop. Вызовы синхронные; вызывающий исключает + * параллельные изменения одного файла. Это не проверка схемы/значений ядра. + * Файловые операции проверяют чтение, UTF-8, NUL и предел строки 1022 байта; + * запись через соседний temp QSaveFile без direct-write fallback, с проверкой commit. + * До успешной замены исходный файл не усекается; ошибки логируются в CONFIG. + * Неправленные строки сохраняются, окончания строк нормализуются в LF. */ #ifndef CONFIG_UPDATER_H #define CONFIG_UPDATER_H @@ -7,8 +12,8 @@ #include enum ConfigUpdateFlag { - CONFIG_UPDATE_REPLACE = 0, // replace existing param (single value) - CONFIG_UPDATE_APPEND = 1 // add new param even if exists (multi-value) + CONFIG_UPDATE_REPLACE = 0, // заменить все вхождения ключа в подходящих секциях + CONFIG_UPDATE_APPEND = 1 // добавить ещё одно значение, сохранив прежние }; /* Один listen-сокет = секция [server:name]. */ @@ -20,17 +25,28 @@ struct ConfigServer { QString netif; // "" = не привязывать к интерфейсу }; -// Batch update — for callers that read/write the file once (e.g. NodeConfig::save) +/* Изменить принадлежащие вызывающему lines, без файлового I/O; true — подготовлено. + * section/key непустые, без внешних пробелов; аргументы не содержат CR/LF/NUL. + * Значение — сырой INI-текст без экранирования. Ошибочные аргументы дают false без правок. + * Тип секции без учёта регистра, суффикс :name с учётом; chat == chatserver. + * replace меняет все дубли; иначе вставка после последнего ключа. При отсутствии ключа + * используется последняя подходящая секция, при отсутствии секции она создаётся в конце. + * Физические ограничения файла проверяются при файловой записи, не этой функцией. */ bool config_update_in_lines(QStringList& lines, const QString& section, const QString& key, const QString& value, bool replace); -// File-level wrapper — reads, updates, writes +/* Прочитать filePath (отсутствующий файл допустим), применить правку и сохранить. + * flags — ConfigUpdateFlag; true только после успешного commit, false с логом ошибки. + * Как и read/modify/write в целом, не обеспечивает атомарность нескольких отдельных вызовов. */ bool config_update_option(const QString& filePath, const QString& section, const QString& param, const QString& value, int flags = CONFIG_UPDATE_REPLACE); -// Полностью заменяет секции [server:*]: удаляет старые, вставляет новые после [global]. -// Внутри секции без пустых строк, между секциями ровно одна пустая строка. +/* Полностью заменить server-секции; вставить блок после первого global, иначе в конец. + * Пустой servers удаляет все server-секции. Остальные секции не изменяются. + * В новом блоке одна пустая строка после каждой секции, transport/type по умолчанию udp/public. + * Имена/поля не могут добавлять заголовки или строки; адреса и enum-значения проверяет вызывающий/ядро. + * Возврат и файловые гарантии — как у config_update_option. */ bool config_rewrite_servers(const QString& filePath, const QList& servers); #endif // CONFIG_UPDATER_H