From f6f3ca8da79be67c8cadb9c2786c55fb2f3afaa0 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 27 Oct 2026 03:14:00 +0100 Subject: [PATCH] Share checked desktop config batches and diagnose write failures --- tools/chatgui/tests/test_config_updater.cpp | 20 ++++++ tools/chatgui/transport/config_updater.cpp | 76 ++++++++++++++------- tools/chatgui/transport/config_updater.h | 16 +++-- 3 files changed, 82 insertions(+), 30 deletions(-) diff --git a/tools/chatgui/tests/test_config_updater.cpp b/tools/chatgui/tests/test_config_updater.cpp index d95d7f38..a9810259 100644 --- a/tools/chatgui/tests/test_config_updater.cpp +++ b/tools/chatgui/tests/test_config_updater.cpp @@ -72,6 +72,26 @@ private slots: QCOMPARE(lines, (QStringList{"[global]", "auto_sockets=yes", "my_node_name=keep", "[gui]", "language=en"})); } + void controlAliasesAreUpdatedTogether() { + QStringList lines{"[control]", "port=1", "control_port=2", "ip=127.0.0.1", "control_ip=127.0.0.2"}; + QVERIFY(config_update_in_lines(lines, "control", "port", "0", true)); + QVERIFY(config_update_in_lines(lines, "control", "control_ip", "::1", true)); + QCOMPARE(lines.count("port=0"), 2); + QCOMPARE(lines.count("control_ip=::1"), 2); + } + + void batchReadKeepsOutputOnError() { + const QStringList original{"preserve caller buffer"}; + auto lines = original; + QVERIFY(!config_read_lines(path(), lines)); + QCOMPARE(lines, original); + QVERIFY(config_read_lines(path(), lines, true)); + QVERIFY(lines.isEmpty()); + QVERIFY(put("[gui]\nlanguage=ru\n")); + QVERIFY(config_read_lines(path(), lines)); + QCOMPARE(lines, (QStringList{"[gui]", "language=ru"})); + } + void invalidArgumentsDoNotMutate() { const QStringList original{"[global]", "my_node_name=keep"}; for (const auto& values : {QStringList{"", "name", "value"}, QStringList{"global", "x=y", "value"}, diff --git a/tools/chatgui/transport/config_updater.cpp b/tools/chatgui/transport/config_updater.cpp index 5d4056ed..d1529e27 100644 --- a/tools/chatgui/transport/config_updater.cpp +++ b/tools/chatgui/transport/config_updater.cpp @@ -28,14 +28,19 @@ static bool single_line(const QString& value) { /* Отказ до изменения lines: аргументы не могут создавать дополнительные строки/секции. */ bool config_update_in_lines(QStringList& lines, const QString& section, const QString& key, const QString& value, bool replace) { - 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"); + const bool sectionValid = !section.trimmed().isEmpty() && section == section.trimmed() && single_line(section) && + !section.contains('[') && !section.contains(']'); + const bool keyValid = !key.trimmed().isEmpty() && key == key.trimmed() && single_line(key) && !key.contains('=') && + !key.contains('[') && !key.contains(']') && !key.startsWith('#') && !key.startsWith(';'); + const bool valueValid = single_line(value); + if (!sectionValid || !keyValid || !valueValid) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config update rejected section_valid=%d key_valid=%d value_single_line=%d", + sectionValid, keyValid, valueValid); return false; } const QString wanted = normalized_section(section); + const bool controlAlias = wanted == "control" && (key == "control_ip" || key == "control_port" || key == "control_allow"); + const QString option = controlAlias ? key.mid(8) : key; const QString replacement = key + '=' + value; int lastSection = -1, lastKey = -1, replaced = 0; bool matching = false; @@ -48,7 +53,11 @@ bool config_update_in_lines(QStringList& lines, const QString& section, } if (!matching) continue; const int equal = lines[i].indexOf('='); - if (equal <= 0 || lines[i].left(equal).trimmed() != key) continue; + if (equal <= 0) continue; + QString candidate = lines[i].left(equal).trimmed(); + if (wanted == "control" && (candidate == "control_ip" || candidate == "control_port" || candidate == "control_allow")) + candidate = candidate.mid(8); + if (candidate != option) continue; lastKey = i; if (replace) { lines[i] = replacement; ++replaced; } } @@ -67,10 +76,10 @@ bool config_update_in_lines(QStringList& lines, const QString& section, } /* ENOENT допускает создание; ошибки чтения и непредставимый UTF-8 запрещают перезапись. */ -static bool read_config_lines(const QString& path, QStringList& lines) { +bool config_read_lines(const QString& path, QStringList& lines, bool allowMissing) { QFile input(path); const QFileInfo info(path); - if (!info.exists() && !info.isSymLink()) return true; + if (allowMissing && !info.exists() && !info.isSymLink()) { lines.clear(); 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; @@ -81,30 +90,38 @@ static bool read_config_lines(const QString& path, QStringList& lines) { 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)); + if (text.toUtf8() != data) { + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config rejected non-UTF-8 text path=%s", qPrintable(path)); return false; } + if (config_ini_validate(data.constData(), size_t(data.size()), qPrintable(path))) return false; + QStringList result; if (!text.isEmpty()) { - lines = text.split('\n'); - if (lines.last().isEmpty()) lines.removeLast(); - for (QString& line : lines) if (line.endsWith('\r')) line.chop(1); + result = text.split('\n'); + if (result.last().isEmpty()) result.removeLast(); + for (QString& line : result) if (line.endsWith('\r')) line.chop(1); } + lines = result; 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) { +bool config_write_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())); + const char *phase = "open"; + bool saved = output.open(QIODevice::WriteOnly); + if (saved) { phase = "write"; saved = output.write(data) == data.size(); } + if (saved) { phase = "commit"; saved = output.commit(); } + if (!saved) { + const int error = errno; + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config save failed path=%s phase=%s bytes=%lld: %s; errno=%d (%s)", + qPrintable(path), phase, (long long)data.size(), qPrintable(output.errorString()), error, strerror(error)); return false; } DEBUG_INFO(DEBUG_CATEGORY_CONFIG, "GUI config saved path=%s bytes=%lld", qPrintable(path), (long long)data.size()); @@ -118,22 +135,28 @@ bool config_update_option(const QString& filePath, const QString& section, return false; } QStringList lines; - if (!read_config_lines(filePath, lines) || + if (!config_read_lines(filePath, lines, true) || !config_update_in_lines(lines, section, param, value, flags == CONFIG_UPDATE_REPLACE)) return false; - return write_config_lines(filePath, lines); + return config_write_lines(filePath, lines); } bool config_rewrite_servers(const QString& filePath, const QList& servers) { QStringList lines; - if (!read_config_lines(filePath, lines)) return false; + if (!config_read_lines(filePath, lines, true)) return false; QStringList block; + int index = 0; 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)); + const struct { const char *field; bool valid; } checks[] = { + {"name", !server.name.trimmed().isEmpty() && server.name == server.name.trimmed() && !server.name.contains(':') && + !server.name.contains('[') && !server.name.contains(']') && single_line(server.name)}, + {"addr", single_line(server.addr)}, {"transport", single_line(server.transport)}, + {"type", single_line(server.type)}, {"netif", single_line(server.netif)} + }; + for (const auto& check : checks) { + if (check.valid) continue; + DEBUG_ERROR(DEBUG_CATEGORY_CONFIG, "GUI config rejected malformed server index=%d field=%s path=%s", + index, check.field, qPrintable(filePath)); return false; } block << ("[server:" + server.name + ']') << ("addr=" + server.addr); @@ -141,6 +164,7 @@ bool config_rewrite_servers(const QString& filePath, const QList& block << ("type=" + (server.type.isEmpty() ? QString("public") : server.type)); if (!server.netif.isEmpty()) block << ("netif=" + server.netif); block << ""; + ++index; } // Удаляем только server-секции целиком, сохраняя прочие строки в прежнем порядке. @@ -161,5 +185,5 @@ bool config_rewrite_servers(const QString& filePath, const QList& } 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); + return config_write_lines(filePath, filtered); } diff --git a/tools/chatgui/transport/config_updater.h b/tools/chatgui/transport/config_updater.h index bf16c808..6ea96fea 100644 --- a/tools/chatgui/transport/config_updater.h +++ b/tools/chatgui/transport/config_updater.h @@ -4,8 +4,8 @@ * запись через соседний temp QSaveFile без direct-write fallback, с проверкой commit. * До успешной замены исходный файл не усекается; ошибки логируются в CONFIG. * Неправленные строки сохраняются, окончания строк нормализуются в LF. */ -#ifndef CONFIG_UPDATER_H -#define CONFIG_UPDATER_H +#ifndef GUI_CONFIG_UPDATER_H +#define GUI_CONFIG_UPDATER_H #include #include @@ -25,10 +25,18 @@ struct ConfigServer { QString netif; // "" = не привязывать к интерфейсу }; +/* Проверенное чтение/сохранение для пакетных правок, одна запись на весь набор. + * read заменяет lines только при успехе; allowMissing принимает отсутствующий файл как пустой. + * write нормализует LF и добавляет конечный LF непустому тексту, проверяет физический INI-формат. + * true — успех; false — ошибка с CONFIG-диагностикой. Правила владения и записи см. в начале файла. */ +bool config_read_lines(const QString& filePath, QStringList& lines, bool allowMissing = false); +bool config_write_lines(const QString& filePath, const QStringList& lines); + /* Изменить принадлежащие вызывающему lines, без файлового I/O; true — подготовлено. * section/key непустые, без внешних пробелов; аргументы не содержат CR/LF/NUL. * Значение — сырой INI-текст без экранирования. Ошибочные аргументы дают false без правок. - * Тип секции без учёта регистра, суффикс :name с учётом; chat == chatserver. + * Тип секции без учёта регистра, суффикс :name с учётом; chat == chatserver; + * в control пары ip/control_ip, port/control_port, allow/control_allow равнозначны. * replace меняет все дубли; иначе вставка после последнего ключа. При отсутствии ключа * используется последняя подходящая секция, при отсутствии секции она создаётся в конце. * Физические ограничения файла проверяются при файловой записи, не этой функцией. */ @@ -49,4 +57,4 @@ bool config_update_option(const QString& filePath, const QString& section, * Возврат и файловые гарантии — как у config_update_option. */ bool config_rewrite_servers(const QString& filePath, const QList& servers); -#endif // CONFIG_UPDATER_H +#endif // GUI_CONFIG_UPDATER_H